diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index befdc8b76..a34fb76b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -674,6 +674,18 @@ jobs: PROGRAMA_CLI_BIN="$CLI_BIN" python3 tests/test_cli_codex_hook_trust.py + - name: Run CLI consent and socket discovery regressions + if: ${{ !cancelled() && steps.cli-build.outcome == 'success' }} + run: | + set -euo pipefail + CLI_BIN="$RUNNER_TEMP/programa-cli-regression/Debug/programa" + test -x "$CLI_BIN" + result=0 + for t in tests/test_cli_noninteractive_install_regression.py tests/test_cli_socket_no_autodiscovery_regression.py; do + PROGRAMA_CLI_BIN="$CLI_BIN" python3 "$t" || result=1 + done + exit "$result" + - name: Run hook confirmation EOF regressions if: ${{ !cancelled() && steps.cli-build.outcome == 'success' }} run: | @@ -1389,6 +1401,19 @@ jobs: -only-testing:programaUITests/MultiWindowNotificationsUITests/testNotifyCLIDoesNotStealFocusAcrossWindows \ test-without-building + - name: Run window close Cancel UI regression + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -derivedDataPath "$PROGRAMA_DERIVED_DATA_DIR" \ + -destination "platform=macOS" \ + -maximum-test-execution-time-allowance 180 \ + -only-testing:programaUITests/WindowCloseConfirmCancelUITests \ + test-without-building + - name: Dump notification regression diagnostics if: failure() run: | diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index cf966b134..4282f1942 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -290,6 +290,7 @@ B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; + BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; @@ -298,6 +299,9 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + BEEF5040A1B2C3D4E5F60003 /* ConfigFixSweepRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */; }; + BEEF50200000000000000004 /* WorkspaceFixSweepWKSPRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */; }; + BEEF5050000000000000A003 /* EscrowFDPassingRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */; }; RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; @@ -699,6 +703,7 @@ B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; + BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCloseConfirmCancelUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; @@ -708,6 +713,9 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFixSweepRegressionTests.swift; sourceTree = ""; }; + BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceFixSweepWKSPRegressionTests.swift; sourceTree = ""; }; + BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EscrowFDPassingRegressionTests.swift; sourceTree = ""; }; RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; @@ -1208,6 +1216,7 @@ B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */, + BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, @@ -1230,6 +1239,9 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + BEEF5040A1B2C3D4E5F60004 /* ConfigFixSweepRegressionTests.swift */, + BEEF50200000000000000003 /* WorkspaceFixSweepWKSPRegressionTests.swift */, + BEEF5050000000000000A004 /* EscrowFDPassingRegressionTests.swift */, RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, @@ -1673,6 +1685,7 @@ B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, + BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, @@ -1695,6 +1708,9 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + BEEF5040A1B2C3D4E5F60003 /* ConfigFixSweepRegressionTests.swift in Sources */, + BEEF50200000000000000004 /* WorkspaceFixSweepWKSPRegressionTests.swift in Sources */, + BEEF5050000000000000A003 /* EscrowFDPassingRegressionTests.swift in Sources */, RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, diff --git a/programaTests/ConfigFixSweepRegressionTests.swift b/programaTests/ConfigFixSweepRegressionTests.swift new file mode 100644 index 000000000..490cc4bb9 --- /dev/null +++ b/programaTests/ConfigFixSweepRegressionTests.swift @@ -0,0 +1,217 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Regression tests for the CONF (config, git, trust) fix cluster that only use APIs that +/// existed before the fixes, so they can be committed first and fail on the unfixed code. +/// +/// Every git repository lives in a test-owned temp directory that is removed in `tearDown`. +/// Repository config is only ever written inside those temp repos, never the real checkout. +final class ConfigFixSweepRegressionTests: XCTestCase { + private var tempRoot: URL! + private var originalPATH: String? + + override func setUpWithError() throws { + try super.setUpWithError() + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("conf-regression-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + // Resolve /var -> /private/var so paths compare equal to what git prints. + tempRoot = base.resolvingSymlinksInPath() + + // Keep the probes network-free: a `gh` that answers `pr list` with no pull requests. + originalPATH = ProcessInfo.processInfo.environment["PATH"] + let binDir = tempRoot.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + let ghStub = binDir.appendingPathComponent("gh") + try "#!/bin/sh\necho '[]'\nexit 0\n".write(to: ghStub, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ghStub.path) + setenv("PATH", "\(binDir.path):\(originalPATH ?? "/usr/bin:/bin")", 1) + } + + override func tearDownWithError() throws { + if let originalPATH { setenv("PATH", originalPATH, 1) } + originalPATH = nil + try? FileManager.default.removeItem(at: tempRoot) + tempRoot = nil + try super.tearDownWithError() + } + + // MARK: - Fixture helpers + + @discardableResult + private func git(_ arguments: [String], in directory: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = directory + var environment = ProcessInfo.processInfo.environment + environment["GIT_CONFIG_GLOBAL"] = "/dev/null" + environment["GIT_CONFIG_SYSTEM"] = "/dev/null" + environment["GIT_AUTHOR_NAME"] = "Conf Test" + environment["GIT_AUTHOR_EMAIL"] = "conf@example.invalid" + environment["GIT_COMMITTER_NAME"] = "Conf Test" + environment["GIT_COMMITTER_EMAIL"] = "conf@example.invalid" + process.environment = environment + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: outData, as: UTF8.self) + guard process.terminationStatus == 0 else { + throw NSError( + domain: "ConfigFixSweepRegressionTests", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: + "git \(arguments.joined(separator: " ")) failed: \(String(decoding: errData, as: UTF8.self))"] + ) + } + return output + } + + /// A repo on `branch` with one commit containing `fileName`. + private func makeRepo(branch: String, fileName: String = "README.md", contents: String = "one\n") throws -> URL { + let repo = tempRoot.appendingPathComponent("repo-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + try git(["init", "-q", "-b", branch], in: repo) + try contents.write(to: repo.appendingPathComponent(fileName), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-q", "-m", "init"], in: repo) + return repo + } + + // MARK: - CONF-01: repository config is untrusted input to automatic git probes + + /// A repository's `core.fsmonitor` names a command git runs on `status`. The sidebar probe + /// runs on whatever directory a terminal reports, so opening a hostile checkout must never + /// execute that command. + func testInitialGitMetadataProbeNeverRunsRepositoryFsmonitorCommand() throws { + let repo = try makeRepo(branch: "feature-x") + let marker = tempRoot.appendingPathComponent("fsmonitor-ran-probe") + // Configure last so none of the fixture's own git commands trip the hook. + try git(["config", "core.fsmonitor", "touch \(marker.path)"], in: repo) + + // Positive control: plain git does run the hook, so an absent marker below really means + // the probe neutralized it rather than that this git never runs it. + try git(["status", "--porcelain"], in: repo) + XCTAssertTrue( + FileManager.default.fileExists(atPath: marker.path), + "control: plain `git status` should execute the hostile core.fsmonitor command" + ) + try FileManager.default.removeItem(at: marker) + + let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + + XCTAssertEqual(snapshot.branch, "feature-x") + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "the sidebar git probe executed the repository's core.fsmonitor command" + ) + } + + func testWorktreeListingNeverRunsRepositoryFsmonitorCommand() throws { + let repo = try makeRepo(branch: "feature-x") + let marker = tempRoot.appendingPathComponent("fsmonitor-ran-worktrees") + try git(["config", "core.fsmonitor", "touch \(marker.path)"], in: repo) + + let entries = GitWorktreeManager.listWorktrees(repoRoot: repo.path) + + XCTAssertEqual(entries?.first?.path, repo.path) + XCTAssertEqual(entries?.first?.branch, "feature-x") + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "worktree listing executed the repository's core.fsmonitor command" + ) + } + + // MARK: - CONF-02: review diff survives hostile or surprising git config and non-ASCII paths + + /// `diff.external` would replace git's diff output with a program's, and `diff.noprefix` + /// changes the path prefixes the parser expects. Non-ASCII names must come back verbatim, + /// not as git's quoted octal form. + func testReviewDiffIgnoresDiffConfigAndKeepsNonASCIIPaths() throws { + let repo = try makeRepo(branch: "main", fileName: "café.md") + try "one\ntwo\n".write(to: repo.appendingPathComponent("café.md"), atomically: true, encoding: .utf8) + try "fresh\n".write(to: repo.appendingPathComponent("naïve.txt"), atomically: true, encoding: .utf8) + try git(["config", "diff.external", "/bin/false"], in: repo) + try git(["config", "diff.noprefix", "true"], in: repo) + + let snapshot = ReviewDiffProber.diffSnapshot(directory: repo.path, mode: .uncommitted, baseBranch: "main") + + XCTAssertNil(snapshot.error) + let tracked = snapshot.files.filter { $0.newPath == "café.md" } + XCTAssertEqual(tracked.count, 1, "files: \(snapshot.files.map(\.displayPath))") + XCTAssertGreaterThanOrEqual(tracked.first?.hunks.count ?? 0, 1) + XCTAssertTrue( + snapshot.files.contains { $0.displayPath == "naïve.txt" }, + "untracked non-ASCII file missing: \(snapshot.files.map(\.displayPath))" + ) + } + + // MARK: - CONF-03: trailing newline is not a context line + + func testDiffParserDoesNotTurnTrailingNewlineIntoContextLine() throws { + let diff = """ + diff --git a/f.txt b/f.txt + index 1111111..2222222 100644 + --- a/f.txt + +++ b/f.txt + @@ -1,2 +1,2 @@ + a + -b + +c + + """ + XCTAssertTrue(diff.hasSuffix("\n")) + + let files = ReviewDiffParser.parse(diff) + + let hunk = try XCTUnwrap(files.first?.hunks.last) + XCTAssertEqual(hunk.lines.count, 3) + let last = try XCTUnwrap(hunk.lines.last) + XCTAssertEqual(last.kind, .addition) + XCTAssertEqual(last.text, "c") + } + + // MARK: - CONF-06: JSONC comments end at any line terminator + + private func parsedJSONC(_ source: String) throws -> [String: Int]? { + let data = try JSONCParser.preprocess(data: Data(source.utf8)) + return try JSONSerialization.jsonObject(with: data) as? [String: Int] + } + + func testJSONCLineCommentEndsAtCRLF() throws { + XCTAssertEqual(try parsedJSONC("{\r\n // c\r\n \"a\": 1\r\n}"), ["a": 1]) + } + + func testJSONCLineCommentEndsAtBareCR() throws { + XCTAssertEqual(try parsedJSONC("{\r // c\r \"a\": 1\r}"), ["a": 1]) + } + + func testJSONCBlockCommentSpanningCRLF() throws { + XCTAssertEqual(try parsedJSONC("{\r\n /* one\r\n two */ \"a\": 1\r\n}"), ["a": 1]) + } + + // MARK: - CONF-14: a hostile base never leaves a worktree or branch behind + + func testWorktreeAddWithOptionLikeBaseFailsAndLeavesNothingBehind() throws { + let repo = try makeRepo(branch: "main") + let worktreePath = tempRoot.appendingPathComponent("wt-x").path + + let outcome = GitWorktreeManager.add(repoRoot: repo.path, branch: "x", base: "--detach", path: worktreePath) + + guard case .gitCommandFailed = outcome else { + return XCTFail("expected .gitCommandFailed for base \"--detach\", got \(outcome)") + } + XCTAssertFalse(GitWorktreeManager.branchExistsLocally("x", repoRoot: repo.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: worktreePath)) + } +} diff --git a/programaTests/EscrowFDPassingRegressionTests.swift b/programaTests/EscrowFDPassingRegressionTests.swift new file mode 100644 index 000000000..2b31bd6e2 --- /dev/null +++ b/programaTests/EscrowFDPassingRegressionTests.swift @@ -0,0 +1,130 @@ +import XCTest +import Darwin + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// SESS-01: a PTY master fd received over the escrow socket, or duplicated from the retained +/// registry, must be close-on-exec (a concurrent fork/exec must not inherit the master), and a +/// peer that attaches several fds must not leak the extras into this process. +/// +/// Every test closes every fd it opens. The sender side of a socketpair stays open until the +/// receive has completed (closing it right after sendmsg can hand the receiver a defunct fd +/// under load). +final class EscrowFDPassingRegressionTests: XCTestCase { + private var openedFDs: [Int32] = [] + + override func tearDown() { + for fd in openedFDs where fcntl(fd, F_GETFD) != -1 { close(fd) } + openedFDs.removeAll() + super.tearDown() + } + + private func track(_ fd: Int32) -> Int32 { + openedFDs.append(fd) + return fd + } + + private func makeSocketPair() throws -> (Int32, Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + guard fds[0] >= 0, fds[1] >= 0 else { throw XCTSkip("socketpair unavailable") } + return (track(fds[0]), track(fds[1])) + } + + private func makePipe() throws -> (read: Int32, write: Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(pipe(&fds), 0) + guard fds[0] >= 0, fds[1] >= 0 else { throw XCTSkip("pipe unavailable") } + return (track(fds[0]), track(fds[1])) + } + + /// Bounded probe, never lsof. + private func openFDCount(limit: Int32 = 1024) -> Int { + (0.. Bool { + let flags = fcntl(fd, F_GETFD) + return flags != -1 && (flags & FD_CLOEXEC) != 0 + } + + /// One sendmsg carrying every fd in `fds` as a single SCM_RIGHTS message. + private func sendRaw(fds: [Int32], payload: [UInt8], over socketFD: Int32) -> Bool { + var payload = payload + return payload.withUnsafeMutableBytes { raw -> Bool in + var iov = iovec(iov_base: raw.baseAddress, iov_len: raw.count) + let space = CMSG_SPACE_COMPAT(fds.count * MemoryLayout.size) + var control = [UInt8](repeating: 0, count: space) + return control.withUnsafeMutableBytes { controlRaw -> Bool in + var msg = msghdr() + msg.msg_iov = withUnsafeMutablePointer(to: &iov) { $0 } + msg.msg_iovlen = 1 + msg.msg_control = controlRaw.baseAddress + msg.msg_controllen = socklen_t(space) + let cmsg = controlRaw.baseAddress!.assumingMemoryBound(to: cmsghdr.self) + cmsg.pointee.cmsg_level = SOL_SOCKET + cmsg.pointee.cmsg_type = SCM_RIGHTS + cmsg.pointee.cmsg_len = socklen_t(CMSG_LEN_COMPAT(fds.count * MemoryLayout.size)) + let dataPtr = controlRaw.baseAddress!.advanced(by: CMSG_LEN_COMPAT(0)) + fds.withUnsafeBytes { dataPtr.copyMemory(from: $0.baseAddress!, byteCount: $0.count) } + return sendmsg(socketFD, &msg, 0) == raw.count + } + } + } + + private func CMSG_LEN_COMPAT(_ length: Int) -> Int { 12 + length } + private func CMSG_SPACE_COMPAT(_ length: Int) -> Int { 12 + ((length + 3) & ~3) } + + func testReceivedDescriptorIsCloseOnExec() throws { + let (sender, receiver) = try makeSocketPair() + let pipeEnds = try makePipe() + XCTAssertTrue(UnixDomainFDPassing.send(fd: pipeEnds.read, payload: Data([0x01]), over: sender)) + guard case .data(let bytes, let received?) = UnixDomainFDPassing.receiveChunk(maxBytes: 8, from: receiver) else { + return XCTFail("expected one byte with an attached fd") + } + _ = track(received) + XCTAssertEqual(bytes, Data([0x01])) + XCTAssertTrue(isCloseOnExec(received), "a received PTY master must not be inherited by a concurrent exec") + } + + func testRetainedDescriptorDuplicateIsCloseOnExec() throws { + let pipeEnds = try makePipe() + // Ownership of the read end moves to the registry; releaseAfterOwnershipTransfer closes it. + let master = fcntl(pipeEnds.read, F_DUPFD, 0) + XCTAssertGreaterThanOrEqual(master, 0) + let owner = SessionEscrowRetainedDescriptor.retain( + sessionId: UUID().uuidString, masterFD: master, childPID: 0, + tokenHex: String(repeating: "ab", count: 32), socketPath: "/tmp/programa-sess01-unused.sock", + recoveryPending: false + ) + defer { owner.releaseAfterOwnershipTransfer() } + guard let copy = owner.duplicate() else { return XCTFail("duplicate returned nil") } + _ = track(copy) + XCTAssertNotEqual(copy, master) + XCTAssertTrue(isCloseOnExec(copy), "the duplicate handed to a holder must not leak into spawned children") + } + + func testPeerAttachingTwoDescriptorsLeaksNeitherExtra() throws { + let (sender, receiver) = try makeSocketPair() + let a = try makePipe() + let b = try makePipe() + XCTAssertTrue(sendRaw(fds: [a.read, b.read], payload: [0x02], over: sender)) + + let before = openFDCount() + let result = UnixDomainFDPassing.receiveChunk(maxBytes: 8, from: receiver) + let after = openFDCount() + + guard case .data(_, let received?) = result else { + // A refused frame is also acceptable, but nothing may have leaked. + XCTAssertEqual(after, before, "a rejected two-fd message must not leave fds installed") + return + } + _ = track(received) + XCTAssertEqual(after - before, 1, "exactly one received fd may remain open") + XCTAssertTrue(isCloseOnExec(received)) + } +} diff --git a/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift b/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift new file mode 100644 index 000000000..23511223e --- /dev/null +++ b/programaTests/WorkspaceFixSweepWKSPRegressionTests.swift @@ -0,0 +1,50 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +/// Red-first regressions for the workspace fix sweep: each test uses only APIs that exist on the +/// pre-fix base commit and fails there. +@MainActor +final class WorkspaceFixSweepWKSPRegressionTests: XCTestCase { + /// A closed agent pane must not leave a phantom "Needs input" badge on the workspace row. + func testPermanentCloseClearsAgentPresenceAndSidebarBadge() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let keep = try XCTUnwrap(workspace.focusedPanelId) + let agentPanel = try XCTUnwrap(workspace.newTerminalSurfaceInFocusedPane(focus: false)).id + workspace.updatePanelAgentState(panelId: agentPanel, state: .blocked, source: .hooks) + XCTAssertNotNil(SidebarAgentIndicator.make(for: workspace)) + + XCTAssertTrue(workspace.closePanel(agentPanel, force: true)) + + XCTAssertNil(workspace.panelAgentPresence[agentPanel]) + XCTAssertNil(SidebarAgentIndicator.make(for: workspace)) + XCTAssertNotNil(workspace.panels[keep]) + } + + /// Two working agents: the stale flag must come from the freshest report, not from whichever + /// presence the dictionary happens to iterate last. Repeated over many UUID pairs so every + /// hash order is exercised. + func testEqualSeverityStaleFlagFollowsFreshestReportRegardlessOfOrder() { + let now = Date() + for _ in 0..<64 { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + let fresh = UUID() + let old = UUID() + workspace.panelAgentPresence = [ + fresh: AgentPresence(state: .working, source: .hooks, lastEventAt: now), + old: AgentPresence( + state: .working, + source: .hooks, + lastEventAt: now.addingTimeInterval(-AgentPresence.staleThreshold - 60) + ), + ] + XCTAssertEqual(SidebarAgentIndicator.make(for: workspace, now: now)?.isStale, false) + } + } +} diff --git a/programaUITests/WindowCloseConfirmCancelUITests.swift b/programaUITests/WindowCloseConfirmCancelUITests.swift new file mode 100644 index 000000000..2b80d4cb6 --- /dev/null +++ b/programaUITests/WindowCloseConfirmCancelUITests.swift @@ -0,0 +1,95 @@ +import XCTest + +/// SHEL-03: closing a window that has a running process asks exactly once, and Cancel keeps the window. +/// Covers the red close button and Ctrl+Cmd+W on the launch window and on a New Window window. +final class WindowCloseConfirmCancelUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testRedButtonCancelKeepsLaunchWindow() { + let app = launchWithRunningProcess() + closeAndCancel(app: app, window: app.windows.firstMatch, useNativeButton: true) + } + + func testCloseShortcutCancelKeepsLaunchWindow() { + let app = launchWithRunningProcess() + closeAndCancel(app: app, window: app.windows.firstMatch, useNativeButton: false) + } + + func testRedButtonCancelKeepsNewWindow() { + let app = launchWithRunningProcess() + let newWindow = openNewWindowWithRunningProcess(app: app) + closeAndCancel(app: app, window: newWindow, useNativeButton: true) + } + + func testCloseShortcutCancelKeepsNewWindow() { + let app = launchWithRunningProcess() + let newWindow = openNewWindowWithRunningProcess(app: app) + closeAndCancel(app: app, window: newWindow, useNativeButton: false) + } + + // MARK: - Helpers + + private func launchWithRunningProcess() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["PROGRAMA_TAG"] = "ui-tests-close-cancel-\(UUID().uuidString.prefix(8).lowercased())" + app.launch() + if !app.wait(for: .runningForeground, timeout: 12) { app.activate() } + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 6)) + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5)) + startSleep(app: app) + return app + } + + private func openNewWindowWithRunningProcess(app: XCUIApplication) -> XCUIElement { + let before = app.windows.count + app.typeKey("n", modifierFlags: [.command, .shift]) + XCTAssertTrue(waitUntil { app.windows.count == before + 1 }, "New Window should add a window") + let newWindow = app.windows.firstMatch + startSleep(app: app) + return newWindow + } + + private func startSleep(app: XCUIApplication) { + app.typeText("sleep 100") + app.typeKey(.return, modifierFlags: []) + // Shell integration needs a moment to report the running command. + Thread.sleep(forTimeInterval: 1.5) + } + + private func closeAndCancel(app: XCUIApplication, window: XCUIElement, useNativeButton: Bool) { + let windowCount = app.windows.count + if useNativeButton { + window.buttons[XCUIIdentifierCloseWindow].click() + } else { + app.typeKey("w", modifierFlags: [.command, .control]) + } + + let cancel = app.dialogs.buttons["Cancel"].exists ? app.dialogs.buttons["Cancel"] : app.sheets.buttons["Cancel"] + let appeared = waitUntil { app.dialogs.buttons["Cancel"].exists || app.sheets.buttons["Cancel"].exists || app.alerts.buttons["Cancel"].exists } + XCTAssertTrue(appeared, "Closing a window with a running process must ask for confirmation") + XCTAssertEqual(app.staticTexts.matching(identifier: "Close window?").count, 1, "Exactly one dialog per close request") + + let target = cancel.exists ? cancel : app.alerts.buttons["Cancel"] + target.firstMatch.click() + + XCTAssertTrue(waitUntil { !app.dialogs.buttons["Cancel"].exists && !app.sheets.buttons["Cancel"].exists && !app.alerts.buttons["Cancel"].exists }) + Thread.sleep(forTimeInterval: 1.0) + XCTAssertEqual(app.windows.count, windowCount, "Cancel must keep the window") + XCTAssertFalse(app.dialogs.buttons["Cancel"].exists, "Cancel must not re-prompt") + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Window kept after Cancel" + attachment.lifetime = .keepAlways + add(attachment) + } + + private func waitUntil(_ condition: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in condition() }, object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: 10) == .completed + } +} diff --git a/tests/test_cli_noninteractive_install_regression.py b/tests/test_cli_noninteractive_install_regression.py new file mode 100644 index 000000000..7541de091 --- /dev/null +++ b/tests/test_cli_noninteractive_install_regression.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Regression (CLIX-10): integration installers never apply or abort silently without consent. + +`programa install-integration` used to exit 0 after "Aborted." when +stdin was not a terminal and no --yes was given, so a script or agent mistook a skipped install +for a successful one. The contract: without --yes/-y and without a TTY the command exits 1, +says "pass --yes" on stderr and leaves the filesystem untouched; --yes and -y both apply. + +Runs the built CLI under a temporary HOME. No socket and no app are involved. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +PROVIDERS = ("claude", "codex", "opencode") +STRIPPED_ENV = ( + "PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID", + "PROGRAMA_SOCKET", "PROGRAMA_SOCKET_PATH", "CLAUDE_CONFIG_DIR", "CODEX_HOME", + "OPENCODE_CONFIG_DIR", "XDG_CONFIG_HOME", +) + + +def run_install(home: str, provider: str, extra: list[str]) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + for key in STRIPPED_ENV: + env.pop(key, None) + sock = os.path.join(home, "no-such.sock") + env.update(HOME=home, CFFIXED_USER_HOME=home, PROGRAMA_SOCKET=sock, PROGRAMA_SOCKET_PATH=sock) + return subprocess.run( + [CLI_PATH, provider, "install-integration", *extra], + stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False, timeout=30, env=env, + ) + + +def files_under(home: str) -> list[str]: + return sorted(str(p.relative_to(home)) for p in Path(home).rglob("*") if p.is_file()) + + +class NonInteractiveInstallTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + + def test_without_consent_exits_one_and_writes_nothing(self) -> None: + for provider in PROVIDERS: + with self.subTest(provider=provider), tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + proc = run_install(home, provider, []) + self.assertEqual(proc.returncode, 1, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertIn("pass --yes", proc.stderr) + self.assertEqual(files_under(home), [], "an unconfirmed install must not create files") + + def test_yes_and_short_flag_both_apply(self) -> None: + for provider in PROVIDERS: + for flag in ("--yes", "-y"): + with self.subTest(provider=provider, flag=flag), tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + proc = run_install(home, provider, [flag]) + self.assertEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotEqual(files_under(home), [], "--yes/-y must apply the install") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_socket_no_autodiscovery_regression.py b/tests/test_cli_socket_no_autodiscovery_regression.py new file mode 100644 index 000000000..7c4233f6e --- /dev/null +++ b/tests/test_cli_socket_no_autodiscovery_regression.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Regression (CLIX-18): the CLI never auto-selects another live Programa instance. + +With no PROGRAMA_SOCKET / PROGRAMA_SOCKET_PATH and nothing listening at the stable +default, `programa ping` used to fall through to any live tagged, debug or staging socket, +so a command meant for the user's app silently landed in a different build. It must not +send them a request and must name them on stderr so the caller can opt in explicitly. + +Stand-ins: a uniquely named /tmp/programa-clix18-*.sock (found by tagged-socket discovery) +always, and /tmp/programa-staging.sock only when that path is free (a real staging socket +is never touched). The test skips if a legacy /tmp/programa.sock exists, because that is a +legitimate implicit default and would answer instead. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import tempfile +import threading +import unittest +import uuid + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +STAGING = "/tmp/programa-staging.sock" +LEGACY_DEFAULT = "/tmp/programa.sock" + + +class StandInListener: + """Answers every request with pong and records what it was sent.""" + + def __init__(self, path: str): + self.path = path + self.requests: list[bytes] = [] + self._stop = threading.Event() + self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._sock.bind(path) + self._sock.listen(8) + self._sock.settimeout(0.05) + self._thread = threading.Thread(target=self._serve, daemon=True) + self._workers: list[threading.Thread] = [] + + def _serve(self) -> None: + while not self._stop.is_set(): + try: + conn, _ = self._sock.accept() + except socket.timeout: + continue + except OSError: + return + worker = threading.Thread(target=self._handle, args=(conn,), daemon=True) + self._workers.append(worker) + worker.start() + + def _handle(self, conn: socket.socket) -> None: + conn.settimeout(0.2) + pending = b"" + try: + while not self._stop.is_set(): + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + return + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + self.requests.append(line) + conn.sendall(b'{"id":"1","ok":true,"result":{"pong":true}}\n') + except OSError: + pass + finally: + conn.close() + + def __enter__(self) -> "StandInListener": + self._thread.start() + return self + + def __exit__(self, *_exc: object) -> None: + self._stop.set() + self._sock.close() + self._thread.join(timeout=2.0) + for worker in self._workers: + worker.join(timeout=2.0) + try: + os.unlink(self.path) + except FileNotFoundError: + pass + + +class NoAutodiscoveryTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + if os.path.exists(LEGACY_DEFAULT): + self.skipTest(f"{LEGACY_DEFAULT} exists and would legitimately answer as the implicit default") + + def ping_without_socket_env(self) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory(prefix="pcli-home-", dir="/tmp") as home: + env = {k: v for k, v in os.environ.items() + if not k.startswith("PROGRAMA_") and k not in ("CMUX_SOCKET_PATH",)} + env.update(HOME=home, CFFIXED_USER_HOME=home) + return subprocess.run([CLI_PATH, "ping"], stdin=subprocess.DEVNULL, capture_output=True, + text=True, check=False, timeout=20, env=env) + + def test_tagged_socket_is_named_not_used(self) -> None: + path = f"/tmp/programa-clix18-{uuid.uuid4().hex[:8]}.sock" + with StandInListener(path) as listener: + proc = self.ping_without_socket_env() + self.assertNotEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotIn("pong", proc.stdout.lower()) + self.assertIn(path, proc.stderr, "stderr must name the live socket the CLI refused to use") + self.assertEqual(listener.requests, [], "no request may be sent to another instance") + + def test_staging_socket_is_named_not_used(self) -> None: + if os.path.exists(STAGING): + self.skipTest(f"{STAGING} exists; never clobber a real staging socket") + with StandInListener(STAGING) as listener: + proc = self.ping_without_socket_env() + self.assertNotEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertNotIn("pong", proc.stdout.lower()) + self.assertIn(STAGING, proc.stderr) + self.assertEqual(listener.requests, [], "no request may be sent to another instance") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_v2/ci_subset.txt b/tests_v2/ci_subset.txt index 5c91ddcf5..36c58f9af 100644 --- a/tests_v2/ci_subset.txt +++ b/tests_v2/ci_subset.txt @@ -43,3 +43,4 @@ test_terminal_close_undo.py test_mcp_server_e2e.py test_opencode_install_integration.py test_tmux_compat_matrix.py +test_socket_error_contract_sweep.py diff --git a/tests_v2/test_socket_error_contract_sweep.py b/tests_v2/test_socket_error_contract_sweep.py new file mode 100644 index 000000000..e5805f94f --- /dev/null +++ b/tests_v2/test_socket_error_contract_sweep.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Socket error-code contract sweep (SOCK-30): typed errors, and no side effects on failure. + +Callers branch on the error code, so each case pins the code: + - workspace.close with an unknown ref -> not_found + - workspace.close on a window's last workspace -> invalid_state (workspace survives) + - notification.create_for_surface, closed surface -> not_found + - surface.focus, unknown id -> not_found, selection unchanged; malformed id -> invalid_params + - markdown.open /dev/zero (device, would never finish reading) -> invalid_params + - browser.state.load with a relative path -> invalid_params + - browser.open_split with an unparseable URL -> invalid_params +""" + +import os +import sys +import time +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from programa_client import ProgramaClient, ProgramaClientError +from v2_support import must as _must + +SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") + + +def _expect_code(c: ProgramaClient, code: str, method: str, params: dict) -> str: + try: + c._call(method, params) + except ProgramaClientError as error: + _must(code in str(error), f"{method} {params!r}: expected {code}, got {error}") + return str(error) + raise ProgramaClientError(f"{method} {params!r}: expected {code}, but the call succeeded") + + +def main() -> int: + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + os.environ.pop(var, None) + + created_window = None + created_workspaces: list[str] = [] + with ProgramaClient(SOCKET_PATH) as c: + try: + # 2. unknown workspace ref + _expect_code(c, "not_found", "workspace.close", {"workspace_id": "workspace:999"}) + + # 3. last workspace of a window. A fresh window has exactly one workspace. + created_window = c.new_window() + time.sleep(0.5) + rows = c.list_workspaces(window_id=created_window) + _must(len(rows) == 1, f"expected a fresh window to hold 1 workspace, got {rows}") + only_ws = rows[0][1] + _expect_code(c, "invalid_state", "workspace.close", {"workspace_id": only_ws, "window_id": created_window}) + rows_after = c.list_workspaces(window_id=created_window) + _must([r[1] for r in rows_after] == [only_ws], f"last workspace must survive: {rows_after}") + c.close_window(created_window) + created_window = None + time.sleep(0.3) + + # Fixtures for the rest: a workspace with a second surface, then close that surface. + ws = c.new_workspace() + created_workspaces.append(ws) + c.select_workspace(ws) + time.sleep(0.3) + extra = c.new_split("right") + time.sleep(0.3) + c.close_surface(extra) + time.sleep(0.3) + + # 7. notify a closed surface + _expect_code(c, "not_found", "notification.create_for_surface", + {"surface_id": extra, "title": "t", "workspace_id": ws}) + + # 8. surface.focus: bad id is not_found with no selection change; malformed is invalid_params + window_before = c.current_window() + ws_before = c.current_workspace() + _expect_code(c, "not_found", "surface.focus", {"surface_id": str(uuid.uuid4())}) + _must(c.current_window() == window_before, "surface.focus failure changed the selected window") + _must(c.current_workspace() == ws_before, "surface.focus failure changed the selected workspace") + _expect_code(c, "invalid_params", "surface.focus", {"surface_id": "not-a-uuid"}) + + # 9. device file + _expect_code(c, "invalid_params", "markdown.open", {"path": "/dev/zero"}) + + # 10. relative path + _expect_code(c, "invalid_params", "browser.state.load", {"path": "relative/state.json"}) + + # 11. unparseable URL (unterminated IPv6 literal) + before = len(c.list_surfaces()) + _expect_code(c, "invalid_params", "browser.open_split", {"url": "http://[::1"}) + _must(len(c.list_surfaces()) == before, "failed browser.open_split must not create a surface") + finally: + if created_window: + try: + c.close_window(created_window) + except Exception: + pass + for w in created_workspaces: + try: + c.close_workspace(w) + except Exception: + pass + + print("PASS: socket error contract sweep") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())