Repository navigation
test: restore cmuxTests compile on main #16285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| public import Foundation | ||
|
|
||
| /// Seconds between `vm.status` polls in `cmux vm wait`. | ||
| /// | ||
| /// `CMUX_VM_WAIT_POLL_SECONDS` lets tests against a mock control socket poll | ||
| /// faster than the production cadence. An override may only shorten the | ||
| /// cadence, so a bad value can never outlive the command's `--timeout`. | ||
| public enum CLIVMWaitPollInterval { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Make this a constructable resolver that stores the injected environment; the package-conventions check rejects this all-static, non-instantiable public enum. Prompt for AI agents |
||
| /// The environment key that overrides the cadence. | ||
| public static let environmentKey = "CMUX_VM_WAIT_POLL_SECONDS" | ||
| /// The production cadence, and the upper bound for an override. | ||
| public static let defaultSeconds: TimeInterval = 3 | ||
| /// The smallest override that is honored. | ||
| public static let minimumOverrideSeconds: TimeInterval = 0.01 | ||
|
|
||
| /// Resolves the poll interval for a process environment. | ||
| /// | ||
| /// - Parameter environment: The CLI process environment. | ||
| /// - Returns: The override when it is a finite value in | ||
| /// `minimumOverrideSeconds...defaultSeconds`, otherwise `defaultSeconds`. | ||
| public static func resolve(environment: [String: String]) -> TimeInterval { | ||
| guard let raw = environment[environmentKey], | ||
| let parsed = TimeInterval(raw), | ||
| parsed.isFinite, | ||
| (minimumOverrideSeconds...defaultSeconds).contains(parsed) else { | ||
| return defaultSeconds | ||
| } | ||
| return parsed | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import CmuxFoundation | ||
|
|
||
| @Suite struct CLIVMWaitPollIntervalTests { | ||
| @Test func usesProductionCadenceWithoutOverride() { | ||
| #expect(CLIVMWaitPollInterval.resolve(environment: [:]) == 3) | ||
| } | ||
|
|
||
| @Test(arguments: ["0.01", "0.05", "3"]) | ||
| func honorsShortOverride(_ raw: String) throws { | ||
| let expected = try #require(TimeInterval(raw)) | ||
| #expect(CLIVMWaitPollInterval.resolve(environment: ["CMUX_VM_WAIT_POLL_SECONDS": raw]) == expected) | ||
| } | ||
|
|
||
| @Test(arguments: ["3600", "3.5", "0.001", "0", "-1", "inf", "nan", "soon", ""]) | ||
| func rejectsOverrideOutsideCommandSafeRange(_ raw: String) { | ||
| #expect(CLIVMWaitPollInterval.resolve(environment: ["CMUX_VM_WAIT_POLL_SECONDS": raw]) == 3) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,58 +1,122 @@ | ||
| import Foundation | ||
| import Testing | ||
|
|
||
| @testable import cmux_cli | ||
|
|
||
| /// Hook state recovery after decode drift, driven through the bundled CLI. | ||
| /// | ||
| /// The CLI is a tool target that this bundle cannot import, so each test seeds | ||
| /// `claude-hook-sessions.json`, runs a real `hooks claude session-start`, and | ||
| /// reads back what the store persisted. | ||
| @Suite(.serialized) | ||
| struct ClaudeHookSessionStoreRecoveryTests { | ||
| private typealias Harness = ClaudeHookLiveDeliveryHarness | ||
|
|
||
| private static let liveWorkspaceId = "11111111-1111-1111-1111-111111111111" | ||
| private static let liveSurfaceId = "22222222-2222-2222-2222-222222222222" | ||
| private static let otherWorkspaceId = "33333333-3333-3333-3333-333333333333" | ||
| private static let otherSurfaceId = "44444444-4444-4444-4444-444444444444" | ||
|
|
||
| @Test("One malformed hook record does not discard valid session mappings") | ||
| func malformedHookRecordDoesNotDiscardValidSessionMappings() throws { | ||
| let validSessionID = "valid-hook-session" | ||
| let data = try JSONSerialization.data(withJSONObject: [ | ||
| let context = try Harness.makeContext(name: "hook-store-salvage") | ||
| defer { context.cleanup() } | ||
| let validSessionId = "valid-hook-session" | ||
| let malformedSessionId = "malformed-hook-session" | ||
| let newSessionId = "new-hook-session" | ||
| let now = Date().timeIntervalSince1970 | ||
| let store: [String: Any] = [ | ||
| "version": 1, | ||
| "sessions": [ | ||
| validSessionID: [ | ||
| "sessionId": validSessionID, | ||
| "workspaceId": "workspace-valid", | ||
| "surfaceId": "surface-valid", | ||
| "startedAt": 1, | ||
| "updatedAt": 2, | ||
| validSessionId: [ | ||
| "sessionId": validSessionId, | ||
| "workspaceId": Self.otherWorkspaceId, | ||
| "surfaceId": Self.otherSurfaceId, | ||
| "cwd": context.root.path, | ||
| "isRestorable": true, | ||
| "startedAt": now, | ||
| "updatedAt": now, | ||
| ], | ||
| "malformed-hook-session": [ | ||
| malformedSessionId: [ | ||
| "sessionId": 42, | ||
| "workspaceId": "workspace-malformed", | ||
| "surfaceId": "surface-malformed", | ||
| "startedAt": 1, | ||
| "updatedAt": 2, | ||
| "workspaceId": Self.otherWorkspaceId, | ||
| "surfaceId": Self.otherSurfaceId, | ||
| "startedAt": now, | ||
| "updatedAt": now, | ||
| ], | ||
| ], | ||
| ]) | ||
| ] | ||
| try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted, .sortedKeys]) | ||
| .write(to: context.storeURL) | ||
| startServer(context: context) | ||
|
|
||
| let result = runSessionStart(context: context, sessionId: newSessionId) | ||
|
|
||
| let decoded = try JSONDecoder().decode(ClaudeHookSessionStoreFile.self, from: data) | ||
| #expect(decoded.sessions.count == 1) | ||
| #expect(decoded.sessions[validSessionID]?.workspaceId == "workspace-valid") | ||
| assertSuccessfulHook(result) | ||
| let saved = try #require( | ||
| JSONSerialization.jsonObject(with: Data(contentsOf: context.storeURL)) as? [String: Any] | ||
| ) | ||
| let sessions = try #require(saved["sessions"] as? [String: Any]) | ||
| #expect(Set(sessions.keys) == [validSessionId, newSessionId]) | ||
| let valid = try #require(sessions[validSessionId] as? [String: Any]) | ||
| #expect(valid["workspaceId"] as? String == Self.otherWorkspaceId) | ||
| #expect(valid["surfaceId"] as? String == Self.otherSurfaceId) | ||
| #expect(quarantineBackups(in: context.root).isEmpty, "A salvageable store must not be quarantined") | ||
| } | ||
|
|
||
| @Test("Repeated hook state quarantine keeps every recovery backup") | ||
| func repeatedHookStateQuarantineKeepsEveryRecoveryBackup() throws { | ||
| let root = FileManager.default.temporaryDirectory | ||
| .appendingPathComponent("cmux-hook-state-quarantine-\(UUID().uuidString)", isDirectory: true) | ||
| try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) | ||
| defer { try? FileManager.default.removeItem(at: root) } | ||
| let stateURL = root.appendingPathComponent("claude-hook-sessions.json", isDirectory: false) | ||
| let environment = ["CMUX_CLAUDE_HOOK_STATE_PATH": stateURL.path] | ||
| let store = ClaudeHookSessionStore(processEnv: environment) | ||
|
|
||
| for _ in 0..<2 { | ||
| try Data(#"{"sessions":["#.utf8).write(to: stateURL, options: .atomic) | ||
| #expect(try store.lookup(sessionId: "any-session") == nil) | ||
| let context = try Harness.makeContext(name: "hook-store-quarantine") | ||
| defer { context.cleanup() } | ||
| startServer(context: context) | ||
|
|
||
| for attempt in 0..<2 { | ||
| try Data(#"{"sessions":["#.utf8).write(to: context.storeURL, options: .atomic) | ||
| let result = runSessionStart(context: context, sessionId: "quarantine-session-\(attempt)") | ||
| assertSuccessfulHook(result) | ||
| } | ||
|
|
||
| let backups = try FileManager.default.contentsOfDirectory( | ||
| #expect(quarantineBackups(in: context.root).count == 2) | ||
| } | ||
|
|
||
| /// One mock server serves every hook process in a test: its accept loop | ||
| /// keeps running until the context closes the listener. | ||
| private func startServer(context: Harness.Context) { | ||
| _ = Harness.startDeliveryTargetServer( | ||
| context: context, | ||
| surfacesByWorkspace: [ | ||
| Self.liveWorkspaceId: [Self.liveSurfaceId], | ||
| Self.otherWorkspaceId: [Self.otherSurfaceId], | ||
| ], | ||
| pidTarget: (workspaceId: Self.liveWorkspaceId, surfaceId: Self.liveSurfaceId) | ||
| ) | ||
| } | ||
|
|
||
| private func runSessionStart( | ||
| context: Harness.Context, | ||
| sessionId: String | ||
| ) -> Harness.ProcessRunResult { | ||
| var environment = Harness.hookEnvironment(context: context) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The CLI child gets a temporary (Based on your team's feedback about isolating CLI test homes.) Prompt for AI agents |
||
| environment["CMUX_WORKSPACE_ID"] = Self.liveWorkspaceId | ||
| environment["CMUX_SURFACE_ID"] = Self.liveSurfaceId | ||
| environment["CMUX_CLAUDE_PID"] = "43401" | ||
| return Harness.runHookProcess( | ||
| context: context, | ||
| arguments: ["hooks", "claude", "session-start"], | ||
| environment: environment, | ||
| standardInput: #"{"session_id":"\#(sessionId)","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# | ||
| ) | ||
| } | ||
|
|
||
| private func quarantineBackups(in root: URL) -> [URL] { | ||
| let entries = (try? FileManager.default.contentsOfDirectory( | ||
| at: root, | ||
| includingPropertiesForKeys: nil, | ||
| options: [] | ||
| ).filter { $0.lastPathComponent.contains(".claude-hook-sessions.json.quarantined.") } | ||
| #expect(backups.count == 2) | ||
| )) ?? [] | ||
| return entries.filter { $0.lastPathComponent.contains(".claude-hook-sessions.json.quarantined.") } | ||
| } | ||
|
|
||
| private func assertSuccessfulHook(_ result: Harness.ProcessRunResult) { | ||
| #expect(!result.timedOut, Comment(rawValue: result.stderr)) | ||
| #expect(result.status == 0, Comment(rawValue: result.stderr)) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,12 +21,12 @@ import CmuxSettings | |
|
|
||
| let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut" | ||
|
|
||
| func drainMainQueue() { | ||
| func drainMainQueue(timeout: TimeInterval = 1.0) { | ||
| let expectation = XCTestExpectation(description: "drain main queue") | ||
| DispatchQueue.main.async { | ||
| expectation.fulfill() | ||
| } | ||
| XCTWaiter().wait(for: [expectation], timeout: 1.0) | ||
| XCTWaiter().wait(for: [expectation], timeout: timeout) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The new 0.1-second calls can return without draining queued main-queue work: the Prompt for AI agents |
||
| } | ||
|
|
||
| @discardableResult | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Replace the static-only enum with a constructable resolver.
The supplied package-conventions pipeline fails at Line 8 because
CLIVMWaitPollIntervalis an all-static, non-instantiable enum. Moving the parser fixes test accessibility but introduces a prohibited ambient API.Make
CLIVMWaitPollIntervala constructable value that receives the environment and owns interval resolution. Construct it in the CLI and use the same value in the package tests. Keep the accepted range and fallback in this single implementation. This first migration cut removes the namespace violation without duplicating policy.As per path instructions, “behavior and state should live on an owning, constructable, injectable type rather than ambient global scope.”
🧰 Tools
🪛 GitHub Actions: iOS tests · 16285/merge · simulator · full suite · both · iOS default · on auto / 4_package-conventions-lint.txt
[error] 8-8: The namespace-type convention check failed:
CLIVMWaitPollIntervalis an all-static, non-instantiable enum. Use an extension on the receiver type or an instantiated value with injected dependencies. Failed step:python3 scripts/tests/test_ios_package_conventions.py(runs./scripts/lint-ios-package-conventions.sh).🪛 GitHub Actions: iOS tests · 16285/merge · simulator · full suite · both · iOS default · on auto / package-conventions-lint
[error] 8-8: Command 'python3 scripts/tests/test_ios_package_conventions.py' (running './scripts/lint-ios-package-conventions.sh') failed: namespace-type convention violation. Enum CLIVMWaitPollInterval exposes an all-static public surface and cannot be instantiated; use an extension on the receiver type or an instantiated value with injected dependencies.
🤖 Prompt for AI Agents
Sources: Path instructions, Pipeline failures