From 46f7d0ec7cd2f6d55a5297fbf7bb933269a1c40e Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 19:00:06 -0700 Subject: [PATCH 1/5] Added file/directory watching. Added more tests. --- README.md | 18 + Sources/Workspace/ChangeEvent.swift | 42 ++ Sources/Workspace/Workspace.swift | 415 ++++++++++++- .../WorkspaceCoverageTests.swift | 544 ------------------ .../WorkspaceFilesystemTests.swift | 506 +++++++++++++++- .../WorkspaceMountingTests.swift | 130 +++++ Tests/WorkspaceTests/WorkspaceTests.swift | 456 ++++++++++++++- 7 files changed, 1525 insertions(+), 586 deletions(-) create mode 100644 Sources/Workspace/ChangeEvent.swift delete mode 100644 Tests/WorkspaceTests/WorkspaceCoverageTests.swift diff --git a/README.md b/README.md index c40a412..a2365b0 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Many agent and tooling flows need more than plain disk I/O: ## What It Provides - `Workspace`: high-level actor API for common file operations and batch edits +- `ChangeEvent`: structured change notifications emitted by `Workspace.watchChanges(at:recursive:)` - `FileSystem`: low-level protocol for custom filesystem backends (see also `ReadableFileSystem` / `WritableFileSystem`) - `ReadWriteFilesystem`: real disk access rooted to a configured directory - `InMemoryFilesystem`: fully in-memory filesystem for isolated sessions and tests @@ -98,6 +99,23 @@ let config = try await workspace.readJSON(Config.self, from: "/config.json") print(config.enabled) // true ``` +### Change Watching + +```swift +import Workspace + +let workspace = Workspace(filesystem: InMemoryFilesystem()) +let changes = await workspace.watchChanges(at: "/notes") + +Task { + for await change in changes { + print(change.kind, change.path) + } +} + +try await workspace.writeFile("/notes/todo.txt", content: "ship it") +``` + ## Common Patterns ### Rooted Disk Workspace diff --git a/Sources/Workspace/ChangeEvent.swift b/Sources/Workspace/ChangeEvent.swift new file mode 100644 index 0000000..4695379 --- /dev/null +++ b/Sources/Workspace/ChangeEvent.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A change observed from a workspace mutation. +public struct ChangeEvent: Sendable, Codable, Equatable { + /// The logical change kind. + public enum Kind: String, Sendable, Codable { + /// A new entry was created. + case created + /// An existing entry's content changed. + case modified + /// An existing entry was deleted. + case deleted + /// An entry was moved or renamed. + case moved + /// An entry was copied to a new path. + case copied + /// An entry's metadata changed without changing content. + case metadataModified + } + + /// The change kind. + public var kind: Kind + /// The affected path after the change. + public var path: WorkspacePath + /// The original path for move and copy operations when applicable. + public var sourcePath: WorkspacePath? + /// The logical kind of the affected node. + public var nodeKind: FileTree.Kind + + /// Creates a change event. + public init( + kind: Kind, + path: WorkspacePath, + sourcePath: WorkspacePath? = nil, + nodeKind: FileTree.Kind + ) { + self.kind = kind + self.path = path + self.sourcePath = sourcePath + self.nodeKind = nodeKind + } +} diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 8d65127..473cca7 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -3,6 +3,13 @@ import Foundation /// A high-level API for reading, editing, and summarizing a workspace. public actor Workspace { private let filesystem: any FileSystem + private var watchers: [UUID: WatchedChangeStream] = [:] + + private struct WatchedChangeStream { + var path: WorkspacePath + var recursive: Bool + var continuation: AsyncStream.Continuation + } /// Creates a workspace backed by `filesystem`. public init(filesystem: any FileSystem) { @@ -16,7 +23,9 @@ public actor Workspace { /// Writes raw file data to the workspace, replacing any existing contents. public func writeData(_ data: Data, to path: WorkspacePath) async throws { + let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) try await filesystem.writeFile(path: path, data: data, append: false) + emit(events) } /// Reads a UTF-8 file from the workspace. @@ -30,12 +39,18 @@ public actor Workspace { /// Writes UTF-8 text to a file, replacing any existing contents. public func writeFile(_ path: WorkspacePath, content: String) async throws { - try await filesystem.writeFile(path: path, data: Data(content.utf8), append: false) + let data = Data(content.utf8) + let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) + try await filesystem.writeFile(path: path, data: data, append: false) + emit(events) } /// Appends UTF-8 text to a file. public func appendFile(_ path: WorkspacePath, content: String) async throws { - try await filesystem.writeFile(path: path, data: Data(content.utf8), append: true) + let data = Data(content.utf8) + let events = try await plannedWriteEvents(path: path, data: data, append: true, on: filesystem) + try await filesystem.writeFile(path: path, data: data, append: true) + emit(events) } /// Reads and decodes JSON from a UTF-8 file. @@ -56,7 +71,34 @@ public actor Workspace { } var data = try encoder.encode(value) data.append(Data("\n".utf8)) + let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) try await filesystem.writeFile(path: path, data: data, append: false) + emit(events) + } + + /// Watches for future changes affecting `path`. + public func watchChanges(at path: WorkspacePath, recursive: Bool = true) -> AsyncStream { + let id = UUID() + var continuation: AsyncStream.Continuation? + let stream = AsyncStream { + continuation = $0 + } + + guard let continuation else { + return stream + } + + watchers[id] = WatchedChangeStream( + path: path, + recursive: recursive, + continuation: continuation + ) + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeWatcher(id: id) + } + } + return stream } /// Returns whether an entry exists at `path`. @@ -84,31 +126,49 @@ public actor Workspace { /// Creates a directory at `path`. public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { + let events = try await plannedDirectoryCreationEvents(path: path, recursive: recursive, on: filesystem) try await filesystem.createDirectory(path: path, recursive: recursive) + emit(events) } /// Removes the entry at `path`. public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { + let events = try await plannedDeletionEvents(at: path, on: filesystem) try await filesystem.remove(path: path, recursive: recursive) + emit(events) } /// Copies an entry from `source` to `destination`. public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws { + let events = try await plannedTransferEvents( + from: source, + to: destination, + kind: .copied, + on: filesystem + ) try await filesystem.copy( from: source, to: destination, recursive: recursive ) + emit(events) } /// Moves or renames an entry from `source` to `destination`. public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { + let events = try await plannedTransferEvents( + from: source, + to: destination, + kind: .moved, + on: filesystem + ) try await filesystem.move( from: source, to: destination ) + emit(events) } /// Builds a recursive tree representation for the entry at `path`. @@ -124,11 +184,13 @@ public actor Workspace { private struct PlannedReplacement { var change: ReplacementResult.Change var updatedContent: String + var nodeKind: FileTree.Kind } private struct PlannedBatchEdit { var edit: FileEdit var entry: FileEdit.Entry + var changeEvents: [ChangeEvent] } private struct DiffToken: Hashable { @@ -159,6 +221,7 @@ public actor Workspace { let plannedChanges = try await plannedReplacementChanges(for: request) var changes = plannedChanges.map(\.change) let touchedPaths = canonicalizedTouchedPaths(for: changes) + var bufferedEvents: [ChangeEvent] = [] guard !changes.isEmpty else { return ReplacementResult( @@ -178,6 +241,7 @@ public actor Workspace { try await write(change: plannedChange, to: filesystem) changes[index].status = .applied appliedIndices.append(index) + bufferedEvents += changeEvents(for: plannedChange) } catch { changes[index].status = .failed let failure = ReplacementResult.Failure(path: plannedChange.change.path, message: describe(error)) @@ -203,6 +267,7 @@ public actor Workspace { for skippedIndex in changes.indices where skippedIndex > index { changes[skippedIndex].status = .skipped } + emit(bufferedEvents) return ReplacementResult( mode: .execution, touchedPaths: touchedPaths, @@ -214,6 +279,7 @@ public actor Workspace { } } + emit(bufferedEvents) return ReplacementResult( mode: .execution, touchedPaths: touchedPaths, @@ -246,6 +312,7 @@ public actor Workspace { let touchedPaths = canonicalizedTouchedPaths(for: edits) let plannedEdits = try await planBatchEdits(edits) var executionEntries = plannedEdits.map(\.entry) + var bufferedEvents: [ChangeEvent] = [] guard !plannedEdits.isEmpty else { return FileEdit.BatchResult( @@ -265,6 +332,7 @@ public actor Workspace { try await apply(plannedEdit.edit, on: filesystem) setStatus(.applied, for: &executionEntries[index]) appliedIndices.append(index) + bufferedEvents += plannedEdit.changeEvents } catch { setStatus(.failed, for: &executionEntries[index]) let failure = FileEdit.Failure( @@ -294,6 +362,7 @@ public actor Workspace { for skippedIndex in executionEntries.indices where skippedIndex > index { setStatus(.skipped, for: &executionEntries[skippedIndex]) } + emit(bufferedEvents) return FileEdit.BatchResult( mode: .execution, touchedPaths: touchedPaths, @@ -305,6 +374,7 @@ public actor Workspace { } } + emit(bufferedEvents) return FileEdit.BatchResult( mode: .execution, touchedPaths: touchedPaths, @@ -433,8 +503,49 @@ public actor Workspace { } } - private func statIfPresent(_ path: WorkspacePath) async throws -> FileInfo? { - try await statIfPresent(path, on: filesystem) + private func removeWatcher(id: UUID) { + watchers.removeValue(forKey: id) + } + + private func emit(_ events: [ChangeEvent]) { + guard !events.isEmpty, !watchers.isEmpty else { + return + } + + let activeWatchers = Array(watchers.values) + for event in events { + for watcher in activeWatchers where shouldDeliver(event, to: watcher) { + watcher.continuation.yield(event) + } + } + } + + private func shouldDeliver(_ event: ChangeEvent, to watcher: WatchedChangeStream) -> Bool { + watchedPathMatches(event.path, watchedPath: watcher.path, recursive: watcher.recursive) + || watchedPathMatches(event.sourcePath, watchedPath: watcher.path, recursive: watcher.recursive) + } + + private func watchedPathMatches( + _ candidate: WorkspacePath?, + watchedPath: WorkspacePath, + recursive: Bool + ) -> Bool { + guard let candidate else { + return false + } + + if recursive { + return isEqualOrDescendant(candidate, of: watchedPath) + } + + return candidate == watchedPath + } + + private func isEqualOrDescendant(_ candidate: WorkspacePath, of ancestor: WorkspacePath) -> Bool { + if ancestor.isRoot { + return true + } + return candidate == ancestor || candidate.string.hasPrefix(ancestor.string + "/") } private func statIfPresent(_ path: WorkspacePath, on target: any FileSystem) async throws -> FileInfo? { @@ -449,6 +560,7 @@ public actor Workspace { var changes: [PlannedReplacement] = [] for path in paths { + let info = try await filesystem.stat(path: path) guard let originalContent = try await readUTF8IfPresent(path, on: filesystem) else { continue } @@ -469,7 +581,8 @@ public actor Workspace { replacements: replacement.count, diff: textDiff(from: originalContent, to: replacement.updatedContent) ), - updatedContent: replacement.updatedContent + updatedContent: replacement.updatedContent, + nodeKind: info.kind ) ) } @@ -553,7 +666,8 @@ public actor Workspace { for edit in edits { let entry = try await planEntry(for: edit, on: planningFilesystem) - plannedEdits.append(PlannedBatchEdit(edit: edit, entry: entry)) + let changeEvents = try await plannedChangeEvents(for: edit, on: planningFilesystem) + plannedEdits.append(PlannedBatchEdit(edit: edit, entry: entry, changeEvents: changeEvents)) try? await apply(edit, on: planningFilesystem) } @@ -562,6 +676,12 @@ public actor Workspace { private func makePlanningFilesystem(for paths: [WorkspacePath]) async throws -> InMemoryFilesystem { let planningFilesystem = InMemoryFilesystem() + let ancestors = Set(paths.flatMap(ancestorPaths(for:))).sorted() + for ancestor in ancestors { + let snapshot = try await shallowSnapshot(path: ancestor, on: filesystem) + try await restore(snapshot, on: planningFilesystem) + } + let snapshots = try await snapshotPaths(paths) for snapshot in snapshots { try await restore(snapshot, on: planningFilesystem) @@ -569,6 +689,16 @@ public actor Workspace { return planningFilesystem } + private func ancestorPaths(for path: WorkspacePath) -> [WorkspacePath] { + var ancestors: [WorkspacePath] = [] + var current = path.dirname + while !current.isRoot { + ancestors.append(current) + current = current.dirname + } + return ancestors.reversed() + } + private func planEntry( for edit: FileEdit, on target: any FileSystem @@ -743,6 +873,235 @@ public actor Workspace { ] } + private func plannedChangeEvents( + for edit: FileEdit, + on target: any FileSystem + ) async throws -> [ChangeEvent] { + switch edit { + case let .writeFile(path, content): + return try await plannedWriteEvents( + path: path, + data: Data(content.utf8), + append: false, + on: target + ) + case let .appendFile(path, content): + return try await plannedWriteEvents( + path: path, + data: Data(content.utf8), + append: true, + on: target + ) + case let .delete(path, _): + return try await plannedDeletionEvents(at: path, on: target) + case let .createDirectory(path, recursive): + return try await plannedDirectoryCreationEvents(path: path, recursive: recursive, on: target) + case let .move(from, to): + return try await plannedTransferEvents(from: from, to: to, kind: .moved, on: target) + case let .copy(from, to, _): + return try await plannedTransferEvents(from: from, to: to, kind: .copied, on: target) + } + } + + private func plannedWriteEvents( + path: WorkspacePath, + data: Data, + append: Bool, + on target: any FileSystem + ) async throws -> [ChangeEvent] { + guard let info = try await statIfPresent(path, on: target) else { + return [ + ChangeEvent( + kind: .created, + path: path, + nodeKind: .file + ), + ] + } + + guard info.kind != .directory else { + return [] + } + + let existingData = try await target.readFile(path: path) + let updatedData = append ? existingData + data : data + guard existingData != updatedData else { + return [] + } + + return [ + ChangeEvent( + kind: .modified, + path: path, + nodeKind: info.kind + ), + ] + } + + private func plannedDirectoryCreationEvents( + path: WorkspacePath, + recursive: Bool, + on target: any FileSystem + ) async throws -> [ChangeEvent] { + guard !path.isRoot else { + return [] + } + + if recursive { + var events: [ChangeEvent] = [] + var current = WorkspacePath.root + for component in path.components { + current = current.appending(component) + if try await statIfPresent(current, on: target) == nil { + events.append( + ChangeEvent( + kind: .created, + path: current, + nodeKind: .directory + ) + ) + } + } + return events + } + + guard try await statIfPresent(path, on: target) == nil else { + return [] + } + + return [ + ChangeEvent( + kind: .created, + path: path, + nodeKind: .directory + ), + ] + } + + private func plannedDeletionEvents( + at path: WorkspacePath, + on target: any FileSystem + ) async throws -> [ChangeEvent] { + let snapshot = try await snapshot(path: path, on: target) + return flattenDeletionEvents(from: snapshot) + } + + private func plannedTransferEvents( + from sourcePath: WorkspacePath, + to destinationPath: WorkspacePath, + kind: ChangeEvent.Kind, + on target: any FileSystem + ) async throws -> [ChangeEvent] { + let snapshot = try await snapshot(path: sourcePath, on: target) + return flattenTransferEvents( + from: snapshot, + sourceRoot: sourcePath, + destinationRoot: destinationPath, + kind: kind + ) + } + + private func changeEvents(for replacement: PlannedReplacement) -> [ChangeEvent] { + guard !replacement.change.diff.hunks.isEmpty else { + return [] + } + + return [ + ChangeEvent( + kind: .modified, + path: replacement.change.path, + nodeKind: replacement.nodeKind + ), + ] + } + + private func flattenDeletionEvents(from snapshot: SnapshotEntry) -> [ChangeEvent] { + switch snapshot { + case .missing: + return [] + case let .file(path, _, _): + return [ChangeEvent(kind: .deleted, path: path, nodeKind: .file)] + case let .symlink(path, _, _): + return [ChangeEvent(kind: .deleted, path: path, nodeKind: .symlink)] + case let .directory(path, _, children): + var events: [ChangeEvent] = [] + for child in children { + events += flattenDeletionEvents(from: child) + } + events.append( + ChangeEvent( + kind: .deleted, + path: path, + nodeKind: .directory + ) + ) + return events + } + } + + private func flattenTransferEvents( + from snapshot: SnapshotEntry, + sourceRoot: WorkspacePath, + destinationRoot: WorkspacePath, + kind: ChangeEvent.Kind + ) -> [ChangeEvent] { + switch snapshot { + case .missing: + return [] + case let .file(path, _, _): + return [ + ChangeEvent( + kind: kind, + path: remappedPath(path, from: sourceRoot, to: destinationRoot), + sourcePath: path, + nodeKind: .file + ), + ] + case let .symlink(path, _, _): + return [ + ChangeEvent( + kind: kind, + path: remappedPath(path, from: sourceRoot, to: destinationRoot), + sourcePath: path, + nodeKind: .symlink + ), + ] + case let .directory(path, _, children): + var events: [ChangeEvent] = [ + ChangeEvent( + kind: kind, + path: remappedPath(path, from: sourceRoot, to: destinationRoot), + sourcePath: path, + nodeKind: .directory + ), + ] + for child in children { + events += flattenTransferEvents( + from: child, + sourceRoot: sourceRoot, + destinationRoot: destinationRoot, + kind: kind + ) + } + return events + } + } + + private func remappedPath( + _ path: WorkspacePath, + from sourceRoot: WorkspacePath, + to destinationRoot: WorkspacePath + ) -> WorkspacePath { + guard path != sourceRoot else { + return destinationRoot + } + + let suffix = path.components.dropFirst(sourceRoot.components.count) + return suffix.reduce(destinationRoot) { partialResult, component in + partialResult.appending(component) + } + } + private func write(change: PlannedReplacement, to target: any FileSystem) async throws { try await target.writeFile( path: change.change.path, @@ -923,35 +1282,59 @@ public actor Workspace { } private func snapshotPaths(_ paths: [WorkspacePath]) async throws -> [SnapshotEntry] { - try await paths.asyncMap { [self] path in - try await self.snapshot(path: path) + try await snapshotPaths(paths, on: filesystem) + } + + private func snapshotPaths( + _ paths: [WorkspacePath], + on target: any FileSystem + ) async throws -> [SnapshotEntry] { + try await paths.asyncMap { path in + try await self.snapshot(path: path, on: target) } } - private func snapshot(path: WorkspacePath) async throws -> SnapshotEntry { - guard await filesystem.exists(path: path) else { + private func shallowSnapshot(path: WorkspacePath, on target: any FileSystem) async throws -> SnapshotEntry { + guard await target.exists(path: path) else { return .missing(path: path) } - let info = try await filesystem.stat(path: path) + let info = try await target.stat(path: path) + switch info.kind { + case .directory: + return .directory(path: path, permissions: info.permissions, children: []) + case .symlink: + let symlinkTarget = try await target.readSymlink(path: path) + return .symlink(path: path, target: symlinkTarget, permissions: info.permissions) + case .file: + return .file(path: path, data: Data(), permissions: info.permissions) + } + } + + private func snapshot(path: WorkspacePath, on target: any FileSystem) async throws -> SnapshotEntry { + guard await target.exists(path: path) else { + return .missing(path: path) + } + + let info = try await target.stat(path: path) if info.kind == .directory { - let entries = try await filesystem.listDirectory(path: path) + let entries = try await target.listDirectory(path: path) let children = try await entries .sorted { $0.name < $1.name } .asyncMap { [self] entry in - try await self.snapshot(path: path.appending(entry.name)) + try await self.snapshot(path: path.appending(entry.name), on: target) } return .directory(path: path, permissions: info.permissions, children: children) } if info.kind == .symlink { - let target = try await filesystem.readSymlink(path: path) - return .symlink(path: path, target: target, permissions: info.permissions) + let symlinkTarget = try await target.readSymlink(path: path) + return .symlink(path: path, target: symlinkTarget, permissions: info.permissions) } return .file( path: path, - data: try await filesystem.readFile(path: path), + data: try await target.readFile(path: path), permissions: info.permissions ) } diff --git a/Tests/WorkspaceTests/WorkspaceCoverageTests.swift b/Tests/WorkspaceTests/WorkspaceCoverageTests.swift deleted file mode 100644 index a777736..0000000 --- a/Tests/WorkspaceTests/WorkspaceCoverageTests.swift +++ /dev/null @@ -1,544 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -private enum WorkspaceCoverageTestSupport { - static func makeTempDirectory(prefix: String = "WorkspaceCoverageTests") throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - static func removeDirectory(_ url: URL) { - try? FileManager.default.removeItem(at: url) - } - - static func data(_ value: String) -> Data { - Data(value.utf8) - } - - static func uniqueSuiteName(prefix: String = "WorkspaceCoverageTests") -> String { - "\(prefix).\(UUID().uuidString)" - } -} - -private actor CoveragePermissionRecorder { - private(set) var requests: [PermissionRequest] = [] - - func record(_ request: PermissionRequest) { - requests.append(request) - } - - func snapshot() -> [PermissionRequest] { - requests - } -} - -@Suite("Workspace Coverage") -struct WorkspaceCoverageTests { - @Test - func `read-write filesystem supports round-trip operations`() async throws { - let root = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteRoot") - defer { WorkspaceCoverageTestSupport.removeDirectory(root) } - - let filesystem = try ReadWriteFilesystem(rootDirectory: root) - - try await filesystem.createDirectory(path: "/docs", recursive: false) - try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceCoverageTestSupport.data("hello"), append: false) - try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceCoverageTestSupport.data(" world"), append: true) - - #expect(try await filesystem.readFile(path: "/docs/note.txt") == WorkspaceCoverageTestSupport.data("hello world")) - - let fileInfo = try await filesystem.stat(path: "/docs/note.txt") - #expect(fileInfo.size == 11) - #expect(fileInfo.kind == .file) - - let directoryEntries = try await filesystem.listDirectory(path: "/docs") - #expect(directoryEntries.map(\.name) == ["note.txt"]) - - try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) - try await filesystem.writeFile(path: "/docs/replaced.txt", data: WorkspaceCoverageTestSupport.data("stale"), append: false) - try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) - #expect(try await filesystem.readFile(path: "/docs/replaced.txt") == WorkspaceCoverageTestSupport.data("hello world")) - - try await filesystem.move(from: "/docs/replaced.txt", to: "/docs/moved.txt") - #expect(!(await filesystem.exists(path: "/docs/replaced.txt"))) - #expect(await filesystem.exists(path: "/docs/moved.txt")) - - try await filesystem.createSymlink(path: "/docs/link.txt", target: "note.txt") - #expect(try await filesystem.readSymlink(path: "/docs/link.txt") == "note.txt") - #expect(try await filesystem.resolveRealPath(path: "/docs/link.txt") == "/docs/note.txt") - - try await filesystem.createHardLink(path: "/docs/hard.txt", target: "/docs/note.txt") - #expect(try await filesystem.readFile(path: "/docs/hard.txt") == WorkspaceCoverageTestSupport.data("hello world")) - - try await filesystem.setPermissions(path: "/docs/note.txt", permissions: POSIXPermissions(0o600)) - let updatedInfo = try await filesystem.stat(path: "/docs/note.txt") - #expect(updatedInfo.permissions == POSIXPermissions(0o600)) - - try await filesystem.createDirectory(path: "/tree/sub", recursive: true) - try await filesystem.writeFile(path: "/tree/sub/deep.txt", data: WorkspaceCoverageTestSupport.data("nested"), append: false) - try await filesystem.copy(from: "/tree", to: "/tree-copy", recursive: true) - #expect(try await filesystem.readFile(path: "/tree-copy/sub/deep.txt") == WorkspaceCoverageTestSupport.data("nested")) - - let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") - #expect(globbed.contains("/docs/note.txt")) - #expect(globbed.contains("/docs/link.txt")) - #expect(globbed.contains("/docs/hard.txt")) - #expect(globbed.contains("/docs/moved.txt")) - - try await filesystem.remove(path: "/docs/moved.txt", recursive: false) - try await filesystem.remove(path: "/tree-copy", recursive: true) - - #expect(!(await filesystem.exists(path: "/docs/moved.txt"))) - #expect(!(await filesystem.exists(path: "/tree-copy"))) - #expect(await filesystem.exists(path: "/")) - } - - @Test - func `read-write filesystem covers errors and unconfigured access`() async throws { - let unconfigured = ReadWriteFilesystem() - - do { - _ = try await unconfigured.stat(path: "/") - Issue.record("expected unconfigured filesystem error") - } catch let error as WorkspaceError { - #expect(error.description.contains("filesystem is not configured")) - } - - #expect(!(await unconfigured.exists(path: "/\u{0}"))) - - let root = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteErrors") - defer { WorkspaceCoverageTestSupport.removeDirectory(root) } - - let filesystem = try ReadWriteFilesystem(rootDirectory: root) - try await filesystem.createDirectory(path: "/dir", recursive: false) - try await filesystem.writeFile(path: "/dir/file.txt", data: WorkspaceCoverageTestSupport.data("x"), append: false) - - do { - _ = try await filesystem.listDirectory(path: "/dir/file.txt") - Issue.record("expected ENOTDIR") - } catch let error as NSError { - #expect(error.domain == NSPOSIXErrorDomain) - #expect(error.code == Int(ENOTDIR)) - } - - do { - try await filesystem.remove(path: "/dir", recursive: false) - Issue.record("expected ENOTEMPTY") - } catch let error as NSError { - #expect(error.domain == NSPOSIXErrorDomain) - #expect(error.code == Int(ENOTEMPTY)) - } - - do { - try await filesystem.copy(from: "/dir", to: "/dir-copy", recursive: false) - Issue.record("expected EISDIR") - } catch let error as NSError { - #expect(error.domain == NSPOSIXErrorDomain) - #expect(error.code == Int(EISDIR)) - } - - try await filesystem.remove(path: "/missing", recursive: false) - #expect(try await filesystem.glob(pattern: "/missing.txt", currentDirectory: "/").isEmpty) - } - - @Test - func `mountable filesystem merges base and synthetic directories`() async throws { - let base = InMemoryFilesystem() - try await base.createDirectory(path: "/docs", recursive: true) - try await base.writeFile(path: "/docs/local.txt", data: WorkspaceCoverageTestSupport.data("local"), append: false) - try await base.writeFile(path: "/base.txt", data: WorkspaceCoverageTestSupport.data("base"), append: false) - - let mountedDocs = InMemoryFilesystem() - try await mountedDocs.writeFile(path: "/guide.txt", data: WorkspaceCoverageTestSupport.data("guide"), append: false) - - let filesystem = MountableFilesystem( - base: base, - mounts: [.init(mountPoint: "/docs/external", filesystem: mountedDocs)] - ) - - let docsInfo = try await filesystem.stat(path: "/docs") - #expect(docsInfo.kind == .directory) - #expect(await filesystem.exists(path: "/docs")) - #expect(try await filesystem.readFile(path: "/base.txt") == WorkspaceCoverageTestSupport.data("base")) - - let docsEntries = try await filesystem.listDirectory(path: "/docs") - #expect(docsEntries.map(\.name) == ["external", "local.txt"]) - - let mountedEntries = try await filesystem.listDirectory(path: "/docs/external") - #expect(mountedEntries.map(\.name) == ["guide.txt"]) - - try await filesystem.writeFile(path: "/docs/external/new.txt", data: WorkspaceCoverageTestSupport.data("new"), append: false) - try await filesystem.createDirectory(path: "/docs/external/sub", recursive: true) - try await filesystem.createSymlink(path: "/docs/external/link.txt", target: "guide.txt") - try await filesystem.createHardLink(path: "/docs/external/hard.txt", target: "/docs/external/guide.txt") - try await filesystem.setPermissions(path: "/docs/external/guide.txt", permissions: POSIXPermissions(0o600)) - - #expect(try await filesystem.readSymlink(path: "/docs/external/link.txt") == "guide.txt") - #expect(try await filesystem.resolveRealPath(path: "/docs/external/link.txt") == "/docs/external/guide.txt") - #expect(try await mountedDocs.readFile(path: "/new.txt") == WorkspaceCoverageTestSupport.data("new")) - #expect(try await mountedDocs.readFile(path: "/hard.txt") == WorkspaceCoverageTestSupport.data("guide")) - #expect(try await mountedDocs.stat(path: "/guide.txt").permissions == POSIXPermissions(0o600)) - - let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") - #expect(globbed.contains("/docs/local.txt")) - #expect(globbed.contains("/docs/external/guide.txt")) - - try await filesystem.remove(path: "/docs/external/new.txt", recursive: false) - #expect(!(await mountedDocs.exists(path: "/new.txt"))) - - do { - _ = try await filesystem.listDirectory(path: "/missing") - Issue.record("expected missing path error") - } catch let error as NSError { - #expect(error.domain == NSPOSIXErrorDomain) - #expect(error.code == Int(ENOENT)) - } - } - - @Test - func `mountable filesystem supports cross-mount directory copy and move`() async throws { - let source = InMemoryFilesystem() - try await source.createDirectory(path: "/tree/sub", recursive: true) - try await source.writeFile(path: "/tree/sub/file.txt", data: WorkspaceCoverageTestSupport.data("nested"), append: false) - try await source.createSymlink(path: "/tree/link.txt", target: "sub/file.txt") - - let destination = InMemoryFilesystem() - let filesystem = MountableFilesystem( - base: InMemoryFilesystem(), - mounts: [ - .init(mountPoint: "/src", filesystem: source), - .init(mountPoint: "/dst", filesystem: destination), - ] - ) - - do { - try await filesystem.copy(from: "/src/tree", to: "/dst/tree", recursive: false) - Issue.record("expected recursive directory copy requirement") - } catch let error as NSError { - #expect(error.domain == NSPOSIXErrorDomain) - #expect(error.code == Int(EISDIR)) - } - - try await filesystem.copy(from: "/src/tree", to: "/dst/tree", recursive: true) - #expect(try await destination.readFile(path: "/tree/sub/file.txt") == WorkspaceCoverageTestSupport.data("nested")) - #expect(try await destination.readSymlink(path: "/tree/link.txt") == "sub/file.txt") - - try await filesystem.move(from: "/src/tree/sub/file.txt", to: "/dst/moved.txt") - #expect(!(await source.exists(path: "/tree/sub/file.txt"))) - #expect(try await destination.readFile(path: "/moved.txt") == WorkspaceCoverageTestSupport.data("nested")) - - do { - try await filesystem.createHardLink(path: "/dst/cross-hard.txt", target: "/src/tree/link.txt") - Issue.record("expected cross-mount hard link rejection") - } catch let error as WorkspaceError { - #expect(error.description.contains("hard links across mounts are not supported")) - } - } - - @Test - func `mountable filesystem supports dynamic mounts and base configuration`() async throws { - let baseRoot = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceMountableBase") - defer { WorkspaceCoverageTestSupport.removeDirectory(baseRoot) } - - let base = ReadWriteFilesystem() - let filesystem = MountableFilesystem(base: base) - try await filesystem.configure(rootDirectory: baseRoot) - - let memory = InMemoryFilesystem() - filesystem.mount("/memory", filesystem: memory) - - try await filesystem.writeFile(path: "/root.txt", data: WorkspaceCoverageTestSupport.data("root"), append: false) - try await filesystem.writeFile(path: "/memory/note.txt", data: WorkspaceCoverageTestSupport.data("memo"), append: false) - - #expect(try await filesystem.readFile(path: "/root.txt") == WorkspaceCoverageTestSupport.data("root")) - #expect(try await memory.readFile(path: "/note.txt") == WorkspaceCoverageTestSupport.data("memo")) - } - - @Test - func `overlay filesystem imports directories and symlinks and proxies mutations`() async throws { - let root = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayCoverage") - defer { WorkspaceCoverageTestSupport.removeDirectory(root) } - - let dirURL = root.appendingPathComponent("dir", isDirectory: true) - try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) - - let fileURL = dirURL.appendingPathComponent("file.txt") - try WorkspaceCoverageTestSupport.data("disk").write(to: fileURL) - try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: fileURL.path) - - let symlinkURL = root.appendingPathComponent("alias.txt") - try FileManager.default.createSymbolicLink(atPath: symlinkURL.path, withDestinationPath: "dir/file.txt") - - let filesystem = try await OverlayFilesystem(rootDirectory: root) - - #expect((try await filesystem.listDirectory(path: "/")).map(\.name) == ["alias.txt", "dir"]) - #expect((try await filesystem.listDirectory(path: "/dir")).map(\.name) == ["file.txt"]) - #expect(try await filesystem.readFile(path: "/alias.txt") == WorkspaceCoverageTestSupport.data("disk")) - #expect(try await filesystem.readSymlink(path: "/alias.txt") == "dir/file.txt") - #expect(try await filesystem.stat(path: "/dir/file.txt").permissions == POSIXPermissions(0o640)) - - try await filesystem.createDirectory(path: "/scratch", recursive: true) - try await filesystem.writeFile(path: "/scratch/note.txt", data: WorkspaceCoverageTestSupport.data("hello"), append: false) - try await filesystem.copy(from: "/scratch/note.txt", to: "/scratch/copy.txt", recursive: false) - try await filesystem.move(from: "/scratch/copy.txt", to: "/scratch/moved.txt") - try await filesystem.createSymlink(path: "/scratch/link.txt", target: "note.txt") - try await filesystem.createHardLink(path: "/scratch/hard.txt", target: "/scratch/note.txt") - try await filesystem.setPermissions(path: "/scratch/note.txt", permissions: POSIXPermissions(0o600)) - - #expect(try await filesystem.readSymlink(path: "/scratch/link.txt") == "note.txt") - #expect(try await filesystem.resolveRealPath(path: "/scratch/link.txt") == "/scratch/note.txt") - #expect(try await filesystem.readFile(path: "/scratch/hard.txt") == WorkspaceCoverageTestSupport.data("hello")) - #expect((try await filesystem.glob(pattern: "/scratch/*.txt", currentDirectory: "/")).contains("/scratch/moved.txt")) - - try await filesystem.remove(path: "/scratch/moved.txt", recursive: false) - #expect(!(await filesystem.exists(path: "/scratch/moved.txt"))) - } - - @Test - func `overlay filesystem reload handles missing and unconfigured roots`() async throws { - let unconfigured = OverlayFilesystem() - - do { - try await unconfigured.reload() - Issue.record("expected missing rootDirectory rejection") - } catch let error as WorkspaceError { - #expect(error.description.contains("requires rootDirectory")) - } - - let root = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayMissingRoot") - try FileManager.default.removeItem(at: root) - - let filesystem = OverlayFilesystem() - try await filesystem.configure(rootDirectory: root) - - #expect(await filesystem.exists(path: "/")) - #expect(!(await filesystem.exists(path: "/missing.txt"))) - } - - @Test - func `permissioned filesystem covers forwarded operations`() async throws { - let base = InMemoryFilesystem() - let recorder = CoveragePermissionRecorder() - let authorizer = PermissionAuthorizer { request in - await recorder.record(request) - return .allow - } - let filesystem = PermissionedFileSystem(base: base, authorizer: authorizer) - - try await filesystem.writeFile(path: "/dir/../note.txt", data: WorkspaceCoverageTestSupport.data("hello"), append: false) - try await filesystem.createDirectory(path: "/links", recursive: true) - try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) - try await filesystem.move(from: "/copy.txt", to: "/moved.txt") - try await filesystem.createSymlink(path: "/links/link.txt", target: "../note.txt") - try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") - - _ = try await filesystem.stat(path: "/note.txt") - _ = try await filesystem.listDirectory(path: "/") - _ = try await filesystem.readFile(path: "/note.txt") - _ = try await filesystem.readSymlink(path: "/links/link.txt") - try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) - _ = try await filesystem.resolveRealPath(path: "/links/link.txt") - #expect(await filesystem.exists(path: "/note.txt")) - _ = try await filesystem.glob(pattern: "*.txt", currentDirectory: "/") - try await filesystem.remove(path: "/hard.txt", recursive: false) - - let requests = await recorder.snapshot() - #expect(requests.map(\.operation) == [ - .writeFile, - .createDirectory, - .copy, - .move, - .createSymlink, - .createHardLink, - .stat, - .listDirectory, - .readFile, - .readSymlink, - .setPermissions, - .resolveRealPath, - .exists, - .glob, - .remove, - ]) - - let symlinkRequest = try #require(requests.first { $0.operation == .createSymlink }) - #expect(symlinkRequest.path == "/links/link.txt") - #expect(symlinkRequest.destinationPath == "/note.txt") - - let globRequest = try #require(requests.first { $0.operation == .glob }) - #expect(globRequest.path == "/*.txt") - #expect(globRequest.destinationPath == "/") - } - - @Test - func `permissioned filesystem covers configure and denial paths`() async throws { - let root = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspacePermissionedConfig") - defer { WorkspaceCoverageTestSupport.removeDirectory(root) } - - let base = ReadWriteFilesystem() - let filesystem = PermissionedFileSystem( - base: base, - authorizer: PermissionAuthorizer { request in - if request.operation == .remove { - return .deny(message: nil) - } - return .allow - } - ) - - try await filesystem.configure(rootDirectory: root) - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceCoverageTestSupport.data("hello"), append: false) - - do { - try await filesystem.remove(path: "/note.txt", recursive: false) - Issue.record("expected denied remove") - } catch let error as WorkspaceError { - #expect(error.description.contains("workspace access denied: remove")) - } - - let deniedExists = await PermissionedFileSystem( - base: base, - authorizer: PermissionAuthorizer { _ in .deny(message: "blocked") } - ).exists(path: "/note.txt") - - #expect(!deniedExists) - #expect(!(await filesystem.exists(path: "/\u{0}"))) - } - - @Test - func `sandbox filesystem url root proxies filesystem operations`() async throws { - let root = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxURL") - defer { WorkspaceCoverageTestSupport.removeDirectory(root) } - - let filesystem = try SandboxFilesystem(root: .url(root)) - - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceCoverageTestSupport.data("hello"), append: false) - try await filesystem.createDirectory(path: "/dir", recursive: true) - try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) - try await filesystem.move(from: "/copy.txt", to: "/moved.txt") - try await filesystem.createSymlink(path: "/link.txt", target: "note.txt") - try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") - try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) - - #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceCoverageTestSupport.data("hello")) - #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") - #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") - #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) - #expect(await filesystem.exists(path: "/hard.txt")) - #expect((try await filesystem.listDirectory(path: "/")).map(\.name).sorted() == ["dir", "hard.txt", "link.txt", "moved.txt", "note.txt"]) - #expect((try await filesystem.glob(pattern: "/*.txt", currentDirectory: "/")).contains("/moved.txt")) - - try await filesystem.remove(path: "/moved.txt", recursive: false) - #expect(!(await filesystem.exists(path: "/moved.txt"))) - } - - @Test - func `sandbox filesystem supports standard roots and rejects invalid app groups`() async throws { - let temporary = try SandboxFilesystem(root: .temporary) - #expect(await temporary.exists(path: "/")) - - #if os(macOS) - let documents = try SandboxFilesystem(root: .documents) - let caches = try SandboxFilesystem(root: .caches) - #expect(await documents.exists(path: "/")) - #expect(await caches.exists(path: "/")) - #endif - - do { - _ = try SandboxFilesystem(root: .appGroup("invalid-group")) - Issue.record("expected invalid app group identifier rejection") - } catch let error as WorkspaceError { - #expect(error.description.contains("invalid app group identifier")) - } - - let firstRoot = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxFirst") - defer { WorkspaceCoverageTestSupport.removeDirectory(firstRoot) } - - let secondRoot = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxSecond") - defer { WorkspaceCoverageTestSupport.removeDirectory(secondRoot) } - - let filesystem = try SandboxFilesystem(root: .url(firstRoot)) - try await filesystem.configure(rootDirectory: secondRoot) - try await filesystem.writeFile(path: "/configured.txt", data: WorkspaceCoverageTestSupport.data("configured"), append: false) - - #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("configured.txt").path)) - } - - @Test - func `user defaults bookmark store round-trips values`() async throws { - let suiteName = WorkspaceCoverageTestSupport.uniqueSuiteName() - defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } - - let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") - let data = WorkspaceCoverageTestSupport.data("bookmark") - - try await store.saveBookmark(data, for: "demo") - #expect(try await store.loadBookmark(for: "demo") == data) - - try await store.deleteBookmark(for: "demo") - #expect(try await store.loadBookmark(for: "demo") == nil) - } - - #if os(macOS) - @Test - func `security-scoped filesystem supports url access and read-only mode`() async throws { - let firstRoot = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedFirst") - defer { WorkspaceCoverageTestSupport.removeDirectory(firstRoot) } - - let secondRoot = try WorkspaceCoverageTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedSecond") - defer { WorkspaceCoverageTestSupport.removeDirectory(secondRoot) } - - let filesystem = try SecurityScopedFilesystem(url: firstRoot, mode: .readWrite) - - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceCoverageTestSupport.data("hello"), append: false) - try await filesystem.createDirectory(path: "/dir", recursive: true) - try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) - try await filesystem.move(from: "/copy.txt", to: "/moved.txt") - try await filesystem.createSymlink(path: "/link.txt", target: "note.txt") - try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") - try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) - - #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceCoverageTestSupport.data("hello")) - #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") - #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") - #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) - #expect(await filesystem.exists(path: "/hard.txt")) - #expect((try await filesystem.listDirectory(path: "/")).map(\.name).sorted() == ["dir", "hard.txt", "link.txt", "moved.txt", "note.txt"]) - #expect((try await filesystem.glob(pattern: "/*.txt", currentDirectory: "/")).contains("/moved.txt")) - - try await filesystem.configure(rootDirectory: secondRoot) - #expect(!(await filesystem.exists(path: "/note.txt"))) - - try await filesystem.writeFile(path: "/fresh.txt", data: WorkspaceCoverageTestSupport.data("fresh"), append: false) - #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("fresh.txt").path)) - - let readOnly = try SecurityScopedFilesystem(url: secondRoot, mode: .readOnly) - #expect(try await readOnly.readFile(path: "/fresh.txt") == WorkspaceCoverageTestSupport.data("fresh")) - - do { - try await readOnly.writeFile(path: "/blocked.txt", data: WorkspaceCoverageTestSupport.data("x"), append: false) - Issue.record("expected read-only rejection") - } catch let error as WorkspaceError { - #expect(error.description.contains("read-only")) - } - } - - @Test - func `security-scoped filesystem reports missing bookmarks`() async throws { - let suiteName = WorkspaceCoverageTestSupport.uniqueSuiteName() - defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } - - let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") - - do { - _ = try await SecurityScopedFilesystem.loadBookmark(id: "missing", store: store) - Issue.record("expected missing bookmark rejection") - } catch let error as WorkspaceError { - #expect(error.description.contains("bookmark not found")) - } - } - #endif -} diff --git a/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift b/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift index 772e230..fe950d6 100644 --- a/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift +++ b/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift @@ -25,11 +25,39 @@ private enum WorkspaceFilesystemTestSupport { static func removeDirectory(_ url: URL) { try? FileManager.default.removeItem(at: url) } + + static func data(_ value: String) -> Data { + Data(value.utf8) + } + + static func uniqueSuiteName(prefix: String = "WorkspaceFilesystemTests") -> String { + "\(prefix).\(UUID().uuidString)" + } +} + +private final class NilAppGroupFileManager: FileManager { + override func containerURL(forSecurityApplicationGroupIdentifier groupIdentifier: String) -> URL? { + nil + } +} + +extension Tag { + @Tag static var permissions: Self + @Tag static var readWrite: Self + @Tag static var inMemory: Self + @Tag static var overlay: Self + @Tag static var sandbox: Self + @Tag static var bookmarks: Self + @Tag static var securityScoped: Self + @Tag static var watching: Self + @Tag static var edits: Self + @Tag static var replacement: Self + @Tag static var tree: Self } @Suite("Workspace Filesystem") struct WorkspaceFilesystemTests { - @Test + @Test(.tags(.permissions)) func `permissioned filesystem normalizes paths and blocks denied writes`() async throws { let base = InMemoryFilesystem() @@ -57,7 +85,7 @@ struct WorkspaceFilesystemTests { #expect(!exists) } - @Test + @Test(.tags(.permissions)) func `permissioned filesystem caches allow-for-session decisions`() async throws { let base = InMemoryFilesystem() try await base.writeFile(path: "/doc.txt", data: Data("hello".utf8), append: false) @@ -77,7 +105,7 @@ struct WorkspaceFilesystemTests { #expect(requests.first?.operation == .readFile) } - @Test + @Test(.tags(.permissions)) func `permissioned mountable filesystem sees mounted virtual paths`() async throws { let docs = InMemoryFilesystem() try await docs.writeFile(path: "/guide.txt", data: Data("guide".utf8), append: false) @@ -104,7 +132,7 @@ struct WorkspaceFilesystemTests { #expect(requests.first?.path == "/docs/guide.txt") } - @Test + @Test(.tags(.readWrite)) func `read-write filesystem rejects symlink escapes outside root`() async throws { let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceFilesystemRoot") defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } @@ -126,7 +154,7 @@ struct WorkspaceFilesystemTests { } } - @Test + @Test(.tags(.inMemory)) func `in-memory filesystem reset clears prior contents`() async throws { let filesystem = InMemoryFilesystem() try await filesystem.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) @@ -139,7 +167,7 @@ struct WorkspaceFilesystemTests { #expect(!(await filesystem.exists(path: "/note.txt"))) } - @Test + @Test(.tags(.overlay)) func `overlay reload restores source snapshot`() async throws { let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayRoot") defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } @@ -157,7 +185,7 @@ struct WorkspaceFilesystemTests { #expect(try await filesystem.readFile(path: "/note.txt") == Data("disk-updated".utf8)) } - @Test + @Test(.tags(.inMemory)) func `in-memory filesystem handles symlink writes copies moves and configure reset`() async throws { let filesystem = InMemoryFilesystem() try await filesystem.writeFile(path: "/target.txt", data: Data("one".utf8), append: false) @@ -184,7 +212,7 @@ struct WorkspaceFilesystemTests { #expect(!(await filesystem.exists(path: "/other/moved.txt"))) } - @Test + @Test(.tags(.inMemory)) func `in-memory filesystem reports POSIX errors for invalid operations`() async throws { let filesystem = InMemoryFilesystem() try await filesystem.writeFile(path: "/file.txt", data: Data("data".utf8), append: false) @@ -288,4 +316,466 @@ struct WorkspaceFilesystemTests { #expect(error.code == Int(EPERM)) } } + + @Test(.tags(.readWrite)) + func `read-write filesystem supports file metadata links globbing and recursive copies`() async throws { + let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteRoot") + defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + + let filesystem = try ReadWriteFilesystem(rootDirectory: root) + + try await filesystem.createDirectory(path: "/docs", recursive: false) + try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceFilesystemTestSupport.data(" world"), append: true) + + #expect(try await filesystem.readFile(path: "/docs/note.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + + let fileInfo = try await filesystem.stat(path: "/docs/note.txt") + #expect(fileInfo.size == 11) + #expect(fileInfo.kind == .file) + let directoryInfo = try await filesystem.stat(path: "/docs") + #expect(directoryInfo.kind == .directory) + + let directoryEntries = try await filesystem.listDirectory(path: "/docs") + #expect(directoryEntries.map(\.name) == ["note.txt"]) + + try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) + try await filesystem.writeFile(path: "/docs/replaced.txt", data: WorkspaceFilesystemTestSupport.data("stale"), append: false) + try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) + #expect(try await filesystem.readFile(path: "/docs/replaced.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + + try await filesystem.move(from: "/docs/replaced.txt", to: "/docs/moved.txt") + #expect(!(await filesystem.exists(path: "/docs/replaced.txt"))) + #expect(await filesystem.exists(path: "/docs/moved.txt")) + + try await filesystem.createSymlink(path: "/docs/link.txt", target: "note.txt") + #expect(try await filesystem.readSymlink(path: "/docs/link.txt") == "note.txt") + #expect(try await filesystem.resolveRealPath(path: "/docs/link.txt") == "/docs/note.txt") + #expect(try await filesystem.stat(path: "/docs/link.txt").kind == .symlink) + + try await filesystem.createHardLink(path: "/docs/hard.txt", target: "/docs/note.txt") + #expect(try await filesystem.readFile(path: "/docs/hard.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + + try await filesystem.setPermissions(path: "/docs/note.txt", permissions: POSIXPermissions(0o600)) + let updatedInfo = try await filesystem.stat(path: "/docs/note.txt") + #expect(updatedInfo.permissions == POSIXPermissions(0o600)) + + try await filesystem.createDirectory(path: "/tree/sub", recursive: true) + try await filesystem.writeFile(path: "/tree/sub/deep.txt", data: WorkspaceFilesystemTestSupport.data("nested"), append: false) + try await filesystem.copy(from: "/tree", to: "/tree-copy", recursive: true) + #expect(try await filesystem.readFile(path: "/tree-copy/sub/deep.txt") == WorkspaceFilesystemTestSupport.data("nested")) + + let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") + #expect(globbed.contains("/docs/note.txt")) + #expect(globbed.contains("/docs/link.txt")) + #expect(globbed.contains("/docs/hard.txt")) + #expect(globbed.contains("/docs/moved.txt")) + + try await filesystem.remove(path: "/docs/moved.txt", recursive: false) + try await filesystem.remove(path: "/tree-copy", recursive: true) + + #expect(!(await filesystem.exists(path: "/docs/moved.txt"))) + #expect(!(await filesystem.exists(path: "/tree-copy"))) + #expect(await filesystem.exists(path: "/")) + } + + @Test(.tags(.readWrite)) + func `read-write filesystem reports configuration and directory operation errors`() async throws { + let unconfigured = ReadWriteFilesystem() + + do { + _ = try await unconfigured.stat(path: "/") + Issue.record("expected unconfigured filesystem error") + } catch let error as WorkspaceError { + #expect(error.description.contains("filesystem is not configured")) + } + + #expect(!(await unconfigured.exists(path: "/\u{0}"))) + + let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteErrors") + defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + + let filesystem = try ReadWriteFilesystem(rootDirectory: root) + try await filesystem.createDirectory(path: "/dir", recursive: false) + try await filesystem.writeFile(path: "/dir/file.txt", data: WorkspaceFilesystemTestSupport.data("x"), append: false) + + do { + _ = try await filesystem.listDirectory(path: "/dir/file.txt") + Issue.record("expected ENOTDIR") + } catch let error as NSError { + #expect(error.domain == NSPOSIXErrorDomain) + #expect(error.code == Int(ENOTDIR)) + } + + do { + try await filesystem.remove(path: "/dir", recursive: false) + Issue.record("expected ENOTEMPTY") + } catch let error as NSError { + #expect(error.domain == NSPOSIXErrorDomain) + #expect(error.code == Int(ENOTEMPTY)) + } + + do { + try await filesystem.copy(from: "/dir", to: "/dir-copy", recursive: false) + Issue.record("expected EISDIR") + } catch let error as NSError { + #expect(error.domain == NSPOSIXErrorDomain) + #expect(error.code == Int(EISDIR)) + } + + try await filesystem.remove(path: "/missing", recursive: false) + #expect(try await filesystem.glob(pattern: "/missing.txt", currentDirectory: "/").isEmpty) + } + + @Test(.tags(.overlay)) + func `overlay filesystem imports disk state and proxies mutations`() async throws { + let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayCoverage") + defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + + let dirURL = root.appendingPathComponent("dir", isDirectory: true) + try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) + + let fileURL = dirURL.appendingPathComponent("file.txt") + try WorkspaceFilesystemTestSupport.data("disk").write(to: fileURL) + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: fileURL.path) + + let symlinkURL = root.appendingPathComponent("alias.txt") + try FileManager.default.createSymbolicLink(atPath: symlinkURL.path, withDestinationPath: "dir/file.txt") + + let filesystem = try await OverlayFilesystem(rootDirectory: root) + + #expect((try await filesystem.listDirectory(path: "/")).map(\.name) == ["alias.txt", "dir"]) + #expect((try await filesystem.listDirectory(path: "/dir")).map(\.name) == ["file.txt"]) + #expect(try await filesystem.readFile(path: "/alias.txt") == WorkspaceFilesystemTestSupport.data("disk")) + #expect(try await filesystem.readSymlink(path: "/alias.txt") == "dir/file.txt") + #expect(try await filesystem.stat(path: "/dir/file.txt").permissions == POSIXPermissions(0o640)) + + try await filesystem.createDirectory(path: "/scratch", recursive: true) + try await filesystem.writeFile(path: "/scratch/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.copy(from: "/scratch/note.txt", to: "/scratch/copy.txt", recursive: false) + try await filesystem.move(from: "/scratch/copy.txt", to: "/scratch/moved.txt") + try await filesystem.createSymlink(path: "/scratch/link.txt", target: "note.txt") + try await filesystem.createHardLink(path: "/scratch/hard.txt", target: "/scratch/note.txt") + try await filesystem.setPermissions(path: "/scratch/note.txt", permissions: POSIXPermissions(0o600)) + + #expect(try await filesystem.readSymlink(path: "/scratch/link.txt") == "note.txt") + #expect(try await filesystem.resolveRealPath(path: "/scratch/link.txt") == "/scratch/note.txt") + #expect(try await filesystem.readFile(path: "/scratch/hard.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect((try await filesystem.glob(pattern: "/scratch/*.txt", currentDirectory: "/")).contains("/scratch/moved.txt")) + + try await filesystem.remove(path: "/scratch/moved.txt", recursive: false) + #expect(!(await filesystem.exists(path: "/scratch/moved.txt"))) + } + + @Test(.tags(.overlay)) + func `overlay filesystem reload requires a configured root and treats missing roots as empty`() async throws { + let unconfigured = OverlayFilesystem() + + do { + try await unconfigured.reload() + Issue.record("expected missing rootDirectory rejection") + } catch let error as WorkspaceError { + #expect(error.description.contains("requires rootDirectory")) + } + + let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayMissingRoot") + try FileManager.default.removeItem(at: root) + + let filesystem = OverlayFilesystem() + try await filesystem.configure(rootDirectory: root) + + #expect(await filesystem.exists(path: "/")) + #expect(!(await filesystem.exists(path: "/missing.txt"))) + } + + @Test(.tags(.permissions)) + func `permissioned filesystem forwards filesystem operations and normalized paths`() async throws { + let base = InMemoryFilesystem() + let recorder = PermissionRecorder() + let authorizer = PermissionAuthorizer { request in + await recorder.record(request) + return .allow + } + let filesystem = PermissionedFileSystem(base: base, authorizer: authorizer) + + try await filesystem.writeFile(path: "/dir/../note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.createDirectory(path: "/links", recursive: true) + try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) + try await filesystem.move(from: "/copy.txt", to: "/moved.txt") + try await filesystem.createSymlink(path: "/links/link.txt", target: "../note.txt") + try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") + + _ = try await filesystem.stat(path: "/note.txt") + _ = try await filesystem.listDirectory(path: "/") + _ = try await filesystem.readFile(path: "/note.txt") + _ = try await filesystem.readSymlink(path: "/links/link.txt") + try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) + _ = try await filesystem.resolveRealPath(path: "/links/link.txt") + #expect(await filesystem.exists(path: "/note.txt")) + _ = try await filesystem.glob(pattern: "*.txt", currentDirectory: "/") + try await filesystem.remove(path: "/hard.txt", recursive: false) + + let requests = await recorder.snapshot() + #expect(requests.map(\.operation) == [ + .writeFile, + .createDirectory, + .copy, + .move, + .createSymlink, + .createHardLink, + .stat, + .listDirectory, + .readFile, + .readSymlink, + .setPermissions, + .resolveRealPath, + .exists, + .glob, + .remove, + ]) + + let symlinkRequest = try #require(requests.first { $0.operation == .createSymlink }) + #expect(symlinkRequest.path == "/links/link.txt") + #expect(symlinkRequest.destinationPath == "/note.txt") + + let globRequest = try #require(requests.first { $0.operation == .glob }) + #expect(globRequest.path == "/*.txt") + #expect(globRequest.destinationPath == "/") + } + + @Test(.tags(.permissions)) + func `permissioned filesystem forwards configuration and denied remove operations`() async throws { + let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspacePermissionedConfig") + defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + + let base = ReadWriteFilesystem() + let filesystem = PermissionedFileSystem( + base: base, + authorizer: PermissionAuthorizer { request in + if request.operation == .remove { + return .deny(message: nil) + } + return .allow + } + ) + + try await filesystem.configure(rootDirectory: root) + try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + + do { + try await filesystem.remove(path: "/note.txt", recursive: false) + Issue.record("expected denied remove") + } catch let error as WorkspaceError { + #expect(error.description.contains("workspace access denied: remove")) + } + + let deniedExists = await PermissionedFileSystem( + base: base, + authorizer: PermissionAuthorizer { _ in .deny(message: "blocked") } + ).exists(path: "/note.txt") + + #expect(!deniedExists) + #expect(!(await filesystem.exists(path: "/\u{0}"))) + } + + @Test(.tags(.sandbox)) + func `sandbox filesystem rooted at a URL supports filesystem operations`() async throws { + let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxURL") + defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + + let filesystem = try SandboxFilesystem(root: .url(root)) + + try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.createDirectory(path: "/dir", recursive: true) + try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) + try await filesystem.move(from: "/copy.txt", to: "/moved.txt") + try await filesystem.createSymlink(path: "/link.txt", target: "note.txt") + try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") + try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) + + #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") + #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") + #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) + #expect(await filesystem.exists(path: "/hard.txt")) + #expect((try await filesystem.listDirectory(path: "/")).map(\.name).sorted() == ["dir", "hard.txt", "link.txt", "moved.txt", "note.txt"]) + #expect((try await filesystem.glob(pattern: "/*.txt", currentDirectory: "/")).contains("/moved.txt")) + + try await filesystem.remove(path: "/moved.txt", recursive: false) + #expect(!(await filesystem.exists(path: "/moved.txt"))) + } + + @Test(.tags(.sandbox)) + func `sandbox filesystem supports standard roots configuration and app-group validation`() async throws { + let temporary = try SandboxFilesystem(root: .temporary) + #expect(await temporary.exists(path: "/")) + + #if os(macOS) + let documents = try SandboxFilesystem(root: .documents) + let caches = try SandboxFilesystem(root: .caches) + #expect(await documents.exists(path: "/")) + #expect(await caches.exists(path: "/")) + #endif + + do { + _ = try SandboxFilesystem(root: .appGroup("invalid-group")) + Issue.record("expected invalid app group identifier rejection") + } catch let error as WorkspaceError { + #expect(error.description.contains("invalid app group identifier")) + } + + do { + _ = try SandboxFilesystem( + root: .appGroup("group.workspace.tests.missing"), + fileManager: NilAppGroupFileManager() + ) + Issue.record("expected unavailable app group rejection") + } catch let error as WorkspaceError { + #expect(error.description.contains("app group container unavailable")) + } + + let firstRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxFirst") + defer { WorkspaceFilesystemTestSupport.removeDirectory(firstRoot) } + + let secondRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxSecond") + defer { WorkspaceFilesystemTestSupport.removeDirectory(secondRoot) } + + let filesystem = try SandboxFilesystem(root: .url(firstRoot)) + try await filesystem.configure(rootDirectory: secondRoot) + try await filesystem.writeFile(path: "/configured.txt", data: WorkspaceFilesystemTestSupport.data("configured"), append: false) + + #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("configured.txt").path)) + } + + @Test(.tags(.bookmarks)) + func `user defaults bookmark store persists and deletes suite values`() async throws { + let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } + + let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") + let data = WorkspaceFilesystemTestSupport.data("bookmark") + + try await store.saveBookmark(data, for: "demo") + #expect(try await store.loadBookmark(for: "demo") == data) + + try await store.deleteBookmark(for: "demo") + #expect(try await store.loadBookmark(for: "demo") == nil) + } + + @Test(.tags(.bookmarks)) + func `user defaults bookmark store supports standard defaults`() async throws { + let id = "standard-\(UUID().uuidString)" + let store = UserDefaultsBookmarkStore(keyPrefix: "workspace.tests.standard.") + let data = WorkspaceFilesystemTestSupport.data("bookmark") + defer { UserDefaults.standard.removeObject(forKey: "workspace.tests.standard." + id) } + + try await store.saveBookmark(data, for: id) + #expect(try await store.loadBookmark(for: id) == data) + + try await store.deleteBookmark(for: id) + #expect(try await store.loadBookmark(for: id) == nil) + } + + #if os(macOS) + @Test(.tags(.securityScoped)) + func `security-scoped filesystem supports url access reconfiguration and read-only mode`() async throws { + let firstRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedFirst") + defer { WorkspaceFilesystemTestSupport.removeDirectory(firstRoot) } + + let secondRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedSecond") + defer { WorkspaceFilesystemTestSupport.removeDirectory(secondRoot) } + + let filesystem = try SecurityScopedFilesystem(url: firstRoot, mode: .readWrite) + + try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.createDirectory(path: "/dir", recursive: true) + try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) + try await filesystem.move(from: "/copy.txt", to: "/moved.txt") + try await filesystem.createSymlink(path: "/link.txt", target: "note.txt") + try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") + try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) + + #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") + #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") + #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) + #expect(await filesystem.exists(path: "/hard.txt")) + #expect((try await filesystem.listDirectory(path: "/")).map(\.name).sorted() == ["dir", "hard.txt", "link.txt", "moved.txt", "note.txt"]) + #expect((try await filesystem.glob(pattern: "/*.txt", currentDirectory: "/")).contains("/moved.txt")) + + try await filesystem.remove(path: "/moved.txt", recursive: false) + #expect(!(await filesystem.exists(path: "/moved.txt"))) + + try await filesystem.configure(rootDirectory: secondRoot) + #expect(!(await filesystem.exists(path: "/note.txt"))) + + try await filesystem.writeFile(path: "/fresh.txt", data: WorkspaceFilesystemTestSupport.data("fresh"), append: false) + #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("fresh.txt").path)) + + let readOnly = try SecurityScopedFilesystem(url: secondRoot, mode: .readOnly) + #expect(try await readOnly.readFile(path: "/fresh.txt") == WorkspaceFilesystemTestSupport.data("fresh")) + + do { + try await readOnly.writeFile(path: "/blocked.txt", data: WorkspaceFilesystemTestSupport.data("x"), append: false) + Issue.record("expected read-only rejection") + } catch let error as WorkspaceError { + #expect(error.description.contains("read-only")) + } + } + + @Test(.tags(.securityScoped, .bookmarks)) + func `security-scoped filesystem reports missing stored bookmarks`() async throws { + let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } + + let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") + + do { + _ = try await SecurityScopedFilesystem.loadBookmark(id: "missing", store: store) + Issue.record("expected missing bookmark rejection") + } catch let error as WorkspaceError { + #expect(error.description.contains("bookmark not found")) + } + } + + @Test(.tags(.securityScoped, .bookmarks)) + func `security-scoped filesystem rejects invalid stored bookmark data`() async throws { + let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } + + let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") + try await store.saveBookmark(WorkspaceFilesystemTestSupport.data("not-a-bookmark"), for: "invalid") + + do { + _ = try await SecurityScopedFilesystem.loadBookmark(id: "invalid", store: store) + Issue.record("expected invalid bookmark rejection") + } catch let error as NSError { + #expect(error.domain == NSCocoaErrorDomain) + } catch { + Issue.record("expected Cocoa bookmark error") + } + } + + @Test(.tags(.securityScoped, .bookmarks)) + func `security-scoped filesystem bookmark creation either saves or reports Cocoa errors`() async throws { + let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedBookmark") + defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + + let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } + + let filesystem = try SecurityScopedFilesystem(url: root, mode: .readWrite) + let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") + + do { + let bookmarkData = try filesystem.makeBookmarkData() + #expect(!bookmarkData.isEmpty) + + try await filesystem.saveBookmark(id: "demo", store: store) + #expect(try await store.loadBookmark(for: "demo") == bookmarkData) + } catch let error as NSError { + #expect(error.domain == NSCocoaErrorDomain) + } + } + #endif } diff --git a/Tests/WorkspaceTests/WorkspaceMountingTests.swift b/Tests/WorkspaceTests/WorkspaceMountingTests.swift index 648cf62..4cf8257 100644 --- a/Tests/WorkspaceTests/WorkspaceMountingTests.swift +++ b/Tests/WorkspaceTests/WorkspaceMountingTests.swift @@ -2,6 +2,23 @@ import Foundation import Testing @testable import Workspace +private enum WorkspaceMountingTestSupport { + static func makeTempDirectory(prefix: String = "WorkspaceMountingTests") throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + static func removeDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } + + static func data(_ value: String) -> Data { + Data(value.utf8) + } +} + @Suite("Workspace Mounting") struct WorkspaceMountingTests { @Test @@ -100,4 +117,117 @@ struct WorkspaceMountingTests { #expect(try await workspace.readFile("/workspace/guide-copy.txt") == "guide") #expect(try await workspace.readFile("/docs/guide.txt") == "guide") } + + @Test + func `mountable filesystem merges base entries with mounted directories`() async throws { + let base = InMemoryFilesystem() + try await base.createDirectory(path: "/docs", recursive: true) + try await base.writeFile(path: "/docs/local.txt", data: WorkspaceMountingTestSupport.data("local"), append: false) + try await base.writeFile(path: "/base.txt", data: WorkspaceMountingTestSupport.data("base"), append: false) + + let mountedDocs = InMemoryFilesystem() + try await mountedDocs.writeFile(path: "/guide.txt", data: WorkspaceMountingTestSupport.data("guide"), append: false) + + let filesystem = MountableFilesystem( + base: base, + mounts: [.init(mountPoint: "/docs/external", filesystem: mountedDocs)] + ) + + let docsInfo = try await filesystem.stat(path: "/docs") + #expect(docsInfo.kind == .directory) + #expect(await filesystem.exists(path: "/docs")) + #expect(try await filesystem.readFile(path: "/base.txt") == WorkspaceMountingTestSupport.data("base")) + + let docsEntries = try await filesystem.listDirectory(path: "/docs") + #expect(docsEntries.map(\.name) == ["external", "local.txt"]) + + let mountedEntries = try await filesystem.listDirectory(path: "/docs/external") + #expect(mountedEntries.map(\.name) == ["guide.txt"]) + + try await filesystem.writeFile(path: "/docs/external/new.txt", data: WorkspaceMountingTestSupport.data("new"), append: false) + try await filesystem.createDirectory(path: "/docs/external/sub", recursive: true) + try await filesystem.createSymlink(path: "/docs/external/link.txt", target: "guide.txt") + try await filesystem.createHardLink(path: "/docs/external/hard.txt", target: "/docs/external/guide.txt") + try await filesystem.setPermissions(path: "/docs/external/guide.txt", permissions: POSIXPermissions(0o600)) + + #expect(try await filesystem.readSymlink(path: "/docs/external/link.txt") == "guide.txt") + #expect(try await filesystem.resolveRealPath(path: "/docs/external/link.txt") == "/docs/external/guide.txt") + #expect(try await mountedDocs.readFile(path: "/new.txt") == WorkspaceMountingTestSupport.data("new")) + #expect(try await mountedDocs.readFile(path: "/hard.txt") == WorkspaceMountingTestSupport.data("guide")) + #expect(try await mountedDocs.stat(path: "/guide.txt").permissions == POSIXPermissions(0o600)) + + let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") + #expect(globbed.contains("/docs/local.txt")) + #expect(globbed.contains("/docs/external/guide.txt")) + + try await filesystem.remove(path: "/docs/external/new.txt", recursive: false) + #expect(!(await mountedDocs.exists(path: "/new.txt"))) + + do { + _ = try await filesystem.listDirectory(path: "/missing") + Issue.record("expected missing path error") + } catch let error as NSError { + #expect(error.domain == NSPOSIXErrorDomain) + #expect(error.code == Int(ENOENT)) + } + } + + @Test + func `mountable filesystem supports directory copy and move across mounts`() async throws { + let source = InMemoryFilesystem() + try await source.createDirectory(path: "/tree/sub", recursive: true) + try await source.writeFile(path: "/tree/sub/file.txt", data: WorkspaceMountingTestSupport.data("nested"), append: false) + try await source.createSymlink(path: "/tree/link.txt", target: "sub/file.txt") + + let destination = InMemoryFilesystem() + let filesystem = MountableFilesystem( + base: InMemoryFilesystem(), + mounts: [ + .init(mountPoint: "/src", filesystem: source), + .init(mountPoint: "/dst", filesystem: destination), + ] + ) + + do { + try await filesystem.copy(from: "/src/tree", to: "/dst/tree", recursive: false) + Issue.record("expected recursive directory copy requirement") + } catch let error as NSError { + #expect(error.domain == NSPOSIXErrorDomain) + #expect(error.code == Int(EISDIR)) + } + + try await filesystem.copy(from: "/src/tree", to: "/dst/tree", recursive: true) + #expect(try await destination.readFile(path: "/tree/sub/file.txt") == WorkspaceMountingTestSupport.data("nested")) + #expect(try await destination.readSymlink(path: "/tree/link.txt") == "sub/file.txt") + + try await filesystem.move(from: "/src/tree/sub/file.txt", to: "/dst/moved.txt") + #expect(!(await source.exists(path: "/tree/sub/file.txt"))) + #expect(try await destination.readFile(path: "/moved.txt") == WorkspaceMountingTestSupport.data("nested")) + + do { + try await filesystem.createHardLink(path: "/dst/cross-hard.txt", target: "/src/tree/link.txt") + Issue.record("expected cross-mount hard link rejection") + } catch let error as WorkspaceError { + #expect(error.description.contains("hard links across mounts are not supported")) + } + } + + @Test + func `mountable filesystem supports dynamic mounts and configurable base storage`() async throws { + let baseRoot = try WorkspaceMountingTestSupport.makeTempDirectory(prefix: "WorkspaceMountableBase") + defer { WorkspaceMountingTestSupport.removeDirectory(baseRoot) } + + let base = ReadWriteFilesystem() + let filesystem = MountableFilesystem(base: base) + try await filesystem.configure(rootDirectory: baseRoot) + + let memory = InMemoryFilesystem() + filesystem.mount("/memory", filesystem: memory) + + try await filesystem.writeFile(path: "/root.txt", data: WorkspaceMountingTestSupport.data("root"), append: false) + try await filesystem.writeFile(path: "/memory/note.txt", data: WorkspaceMountingTestSupport.data("memo"), append: false) + + #expect(try await filesystem.readFile(path: "/root.txt") == WorkspaceMountingTestSupport.data("root")) + #expect(try await memory.readFile(path: "/note.txt") == WorkspaceMountingTestSupport.data("memo")) + } } diff --git a/Tests/WorkspaceTests/WorkspaceTests.swift b/Tests/WorkspaceTests/WorkspaceTests.swift index f27de65..594668d 100644 --- a/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tests/WorkspaceTests/WorkspaceTests.swift @@ -217,6 +217,61 @@ private final class MinimalFilesystem: FileSystem, @unchecked Sendable { } } +private actor ChangeEventRecorder { + private var events: [ChangeEvent] = [] + + func append(_ event: ChangeEvent) { + events.append(event) + } + + func snapshot() -> [ChangeEvent] { + events + } +} + +private enum ChangeWatchTestError: Error { + case timeout(expected: Int, actual: Int) +} + +private func startRecording( + _ stream: AsyncStream, + into recorder: ChangeEventRecorder +) -> Task { + Task { + for await event in stream { + await recorder.append(event) + } + } +} + +private func waitForRecordedEvents( + _ expectedCount: Int, + recorder: ChangeEventRecorder, + timeout: Duration = .seconds(1) +) async throws -> [ChangeEvent] { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while clock.now < deadline { + let snapshot = await recorder.snapshot() + if snapshot.count >= expectedCount { + return Array(snapshot.prefix(expectedCount)) + } + try await Task.sleep(for: .milliseconds(10)) + } + + let snapshot = await recorder.snapshot() + throw ChangeWatchTestError.timeout(expected: expectedCount, actual: snapshot.count) +} + +private func waitForSettledEvents( + recorder: ChangeEventRecorder, + settling: Duration = .milliseconds(50) +) async throws -> [ChangeEvent] { + try await Task.sleep(for: settling) + return await recorder.snapshot() +} + private struct DemoConfig: Codable, Equatable, Sendable { var name: String var enabled: Bool @@ -234,6 +289,29 @@ private func removedLineTexts(_ diff: TextDiff?) -> [String] { diffLines(diff).filter { $0.kind == .removed }.map(\.text) } +private func assertBasicWatchSemantics( + workspace: Workspace, + watchedPath: WorkspacePath +) async throws { + let recorder = ChangeEventRecorder() + let stream = await workspace.watchChanges(at: watchedPath, recursive: false) + let task = startRecording(stream, into: recorder) + defer { task.cancel() } + + try await workspace.writeFile(watchedPath, content: "one") + try await workspace.writeFile(watchedPath, content: "two") + try await workspace.removeItem(at: watchedPath) + + let events = try await waitForRecordedEvents(3, recorder: recorder) + #expect( + events == [ + ChangeEvent(kind: .created, path: watchedPath, nodeKind: .file), + ChangeEvent(kind: .modified, path: watchedPath, nodeKind: .file), + ChangeEvent(kind: .deleted, path: watchedPath, nodeKind: .file), + ] + ) +} + @Suite("Workspace") struct WorkspaceTests { @Test @@ -259,6 +337,328 @@ struct WorkspaceTests { #expect(loaded == DemoConfig(name: "demo", enabled: true)) } + @Test + func `workspace supports binary data appends directory listings and globbing`() async throws { + let state = Workspace(filesystem: InMemoryFilesystem()) + + try await state.createDirectory(at: "/", recursive: false) + try await state.createDirectory(at: "/docs", recursive: false) + try await state.writeData(Data([0xDE, 0xAD, 0xBE, 0xEF]), to: "/docs/blob.bin") + try await state.writeFile("/docs/note.txt", content: "one") + try await state.appendFile("/docs/note.txt", content: " two") + + #expect(try await state.readData(from: "/docs/blob.bin") == Data([0xDE, 0xAD, 0xBE, 0xEF])) + #expect(try await state.readFile("/docs/note.txt") == "one two") + + let info = try await state.fileInfo(at: "/docs/note.txt") + #expect(info.kind == .file) + #expect(info.size == 7) + + let entries = try await state.listDirectory(at: "/docs") + #expect(entries.map(\.name) == ["blob.bin", "note.txt"]) + + let globbed = try await state.glob("/docs/*", currentDirectory: "/") + #expect(globbed == ["/docs/blob.bin", "/docs/note.txt"]) + #expect(await state.exists("/docs/blob.bin")) + + do { + _ = try await state.readFile("/docs/blob.bin") + Issue.record("expected invalid UTF-8 error") + } catch let error as WorkspaceError { + #expect(error.description.contains("not valid UTF-8")) + } + } + + @Test(.tags(.watching)) + func `watchChanges emits create modify and delete for a file`() async throws { + let state = Workspace(filesystem: InMemoryFilesystem()) + let recorder = ChangeEventRecorder() + let stream = await state.watchChanges(at: "/note.txt", recursive: false) + let task = startRecording(stream, into: recorder) + defer { task.cancel() } + + try await state.writeFile("/note.txt", content: "one") + try await state.writeFile("/note.txt", content: "one") + try await state.writeFile("/note.txt", content: "two") + try await state.removeItem(at: "/note.txt") + + let events = try await waitForRecordedEvents(3, recorder: recorder) + #expect( + events == [ + ChangeEvent(kind: .created, path: "/note.txt", nodeKind: .file), + ChangeEvent(kind: .modified, path: "/note.txt", nodeKind: .file), + ChangeEvent(kind: .deleted, path: "/note.txt", nodeKind: .file), + ] + ) + } + + @Test(.tags(.watching)) + func `watchChanges recursively emits directory file and symlink copy events`() async throws { + let fs = InMemoryFilesystem() + try await fs.createDirectory(path: "/docs/archive", recursive: true) + try await fs.writeFile(path: "/docs/archive/file.txt", data: Data("hello".utf8), append: false) + try await fs.createSymlink(path: "/docs/archive/link.txt", target: "file.txt") + let state = Workspace(filesystem: fs) + + let recorder = ChangeEventRecorder() + let stream = await state.watchChanges(at: "/docs") + let task = startRecording(stream, into: recorder) + defer { task.cancel() } + + try await state.copyItem(from: "/docs/archive", to: "/docs/copy") + + let events = try await waitForRecordedEvents(3, recorder: recorder) + #expect( + events == [ + ChangeEvent( + kind: .copied, + path: "/docs/copy", + sourcePath: "/docs/archive", + nodeKind: .directory + ), + ChangeEvent( + kind: .copied, + path: "/docs/copy/file.txt", + sourcePath: "/docs/archive/file.txt", + nodeKind: .file + ), + ChangeEvent( + kind: .copied, + path: "/docs/copy/link.txt", + sourcePath: "/docs/archive/link.txt", + nodeKind: .symlink + ), + ] + ) + } + + @Test(.tags(.watching)) + func `watchChanges emits missing directory creation and symlink deletion events`() async throws { + let fs = InMemoryFilesystem() + try await fs.writeFile(path: "/target.txt", data: Data("hello".utf8), append: false) + try await fs.createSymlink(path: "/link.txt", target: "target.txt") + let state = Workspace(filesystem: fs) + + let directoryRecorder = ChangeEventRecorder() + let symlinkRecorder = ChangeEventRecorder() + let directoryTask = startRecording(await state.watchChanges(at: "/folder", recursive: false), into: directoryRecorder) + let symlinkTask = startRecording(await state.watchChanges(at: "/link.txt", recursive: false), into: symlinkRecorder) + defer { + directoryTask.cancel() + symlinkTask.cancel() + } + + try await state.createDirectory(at: "/folder", recursive: false) + try await state.createDirectory(at: "/folder", recursive: true) + try await state.removeItem(at: "/link.txt", recursive: false) + + let directoryEvents = try await waitForRecordedEvents(1, recorder: directoryRecorder) + let symlinkEvents = try await waitForRecordedEvents(1, recorder: symlinkRecorder) + #expect(directoryEvents == [ChangeEvent(kind: .created, path: "/folder", nodeKind: .directory)]) + #expect(symlinkEvents == [ChangeEvent(kind: .deleted, path: "/link.txt", nodeKind: .symlink)]) + + let settledDirectoryEvents = try await waitForSettledEvents(recorder: directoryRecorder) + #expect(settledDirectoryEvents == directoryEvents) + } + + @Test(.tags(.watching)) + func `watchChanges matches move events by source and destination paths`() async throws { + let fs = InMemoryFilesystem() + try await fs.createDirectory(path: "/docs", recursive: true) + try await fs.createDirectory(path: "/archive", recursive: true) + try await fs.writeFile(path: "/docs/file.txt", data: Data("hello".utf8), append: false) + let state = Workspace(filesystem: fs) + + let docsRecorder = ChangeEventRecorder() + let archiveRecorder = ChangeEventRecorder() + let docsTask = startRecording(await state.watchChanges(at: "/docs"), into: docsRecorder) + let archiveTask = startRecording(await state.watchChanges(at: "/archive"), into: archiveRecorder) + defer { + docsTask.cancel() + archiveTask.cancel() + } + + try await state.moveItem(from: "/docs/file.txt", to: "/archive/file.txt") + + let expected = ChangeEvent( + kind: .moved, + path: "/archive/file.txt", + sourcePath: "/docs/file.txt", + nodeKind: .file + ) + let docsEvents = try await waitForRecordedEvents(1, recorder: docsRecorder) + let archiveEvents = try await waitForRecordedEvents(1, recorder: archiveRecorder) + #expect(docsEvents == [expected]) + #expect(archiveEvents == [expected]) + } + + @Test(.tags(.edits, .watching)) + func `applyEdits rollback emits no watch events`() async throws { + let state = Workspace( + filesystem: FailOnceFilesystem( + base: InMemoryFilesystem(), + failingWritePaths: ["/b.txt"] + ) + ) + let recorder = ChangeEventRecorder() + let stream = await state.watchChanges(at: "/") + let task = startRecording(stream, into: recorder) + defer { task.cancel() } + + let result = try await state.applyEdits( + [ + .writeFile(path: "/a.txt", content: "one"), + .writeFile(path: "/b.txt", content: "two"), + ], + failurePolicy: .rollback + ) + + #expect(result.rolledBack) + let events = try await waitForSettledEvents(recorder: recorder) + #expect(events.isEmpty) + } + + @Test(.tags(.edits, .watching)) + func `applyEdits fail-fast emits only persisted watch events`() async throws { + let state = Workspace( + filesystem: FailOnceFilesystem( + base: InMemoryFilesystem(), + failingWritePaths: ["/b.txt"] + ) + ) + let recorder = ChangeEventRecorder() + let stream = await state.watchChanges(at: "/") + let task = startRecording(stream, into: recorder) + defer { task.cancel() } + + let result = try await state.applyEdits( + [ + .writeFile(path: "/a.txt", content: "one"), + .writeFile(path: "/b.txt", content: "two"), + .writeFile(path: "/c.txt", content: "three"), + ], + failurePolicy: .failFast + ) + + #expect(!result.rolledBack) + let events = try await waitForRecordedEvents(1, recorder: recorder) + #expect(events == [ChangeEvent(kind: .created, path: "/a.txt", nodeKind: .file)]) + let settled = try await waitForSettledEvents(recorder: recorder) + #expect(settled == events) + } + + @Test(.tags(.edits, .watching)) + func `applyEdits best-effort emits only applied watch events`() async throws { + let state = Workspace( + filesystem: FailOnceFilesystem( + base: InMemoryFilesystem(), + failingWritePaths: ["/b.txt"] + ) + ) + let recorder = ChangeEventRecorder() + let stream = await state.watchChanges(at: "/") + let task = startRecording(stream, into: recorder) + defer { task.cancel() } + + let result = try await state.applyEdits( + [ + .writeFile(path: "/a.txt", content: "one"), + .writeFile(path: "/b.txt", content: "two"), + .writeFile(path: "/c.txt", content: "three"), + ], + failurePolicy: .bestEffort + ) + + #expect(!result.rolledBack) + let events = try await waitForRecordedEvents(2, recorder: recorder) + #expect( + events == [ + ChangeEvent(kind: .created, path: "/a.txt", nodeKind: .file), + ChangeEvent(kind: .created, path: "/c.txt", nodeKind: .file), + ] + ) + } + + @Test(.tags(.replacement, .watching)) + func `applyReplacement emits only persisted watch events`() async throws { + let base = InMemoryFilesystem() + try await base.createDirectory(path: "/src", recursive: true) + try await base.writeFile(path: "/src/a.txt", data: Data("foo".utf8), append: false) + try await base.writeFile(path: "/src/b.txt", data: Data("foo".utf8), append: false) + let state = Workspace( + filesystem: FailOnceFilesystem( + base: base, + failingWritePaths: ["/src/b.txt"] + ) + ) + let recorder = ChangeEventRecorder() + let stream = await state.watchChanges(at: "/src") + let task = startRecording(stream, into: recorder) + defer { task.cancel() } + + let result = try await state.applyReplacement( + ReplacementRequest(pattern: "/src/*.txt", search: "foo", replacement: "bar"), + failurePolicy: .bestEffort + ) + + #expect(!result.rolledBack) + let events = try await waitForRecordedEvents(1, recorder: recorder) + #expect(events == [ChangeEvent(kind: .modified, path: "/src/a.txt", nodeKind: .file)]) + let settled = try await waitForSettledEvents(recorder: recorder) + #expect(settled == events) + } + + @Test(.tags(.watching)) + func `watchChanges behaves consistently for in-memory overlay and mounted workspaces`() async throws { + try await assertBasicWatchSemantics( + workspace: Workspace(filesystem: InMemoryFilesystem()), + watchedPath: "/note.txt" + ) + + let overlayRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayWatch") + defer { WorkspaceTestSupport.removeDirectory(overlayRoot) } + let overlayWorkspace = Workspace(filesystem: try await OverlayFilesystem(rootDirectory: overlayRoot)) + try await assertBasicWatchSemantics( + workspace: overlayWorkspace, + watchedPath: "/overlay.txt" + ) + + let mountedWorkspace = Workspace( + filesystem: MountableFilesystem( + base: InMemoryFilesystem(), + mounts: [ + .init(mountPoint: "/mounted", filesystem: InMemoryFilesystem()), + ] + ) + ) + try await assertBasicWatchSemantics( + workspace: mountedWorkspace, + watchedPath: "/mounted/note.txt" + ) + } + + @Test(.tags(.watching)) + func `watchChanges unregisters cancelled streams`() async throws { + let state = Workspace(filesystem: InMemoryFilesystem()) + let recorder = ChangeEventRecorder() + + do { + let stream = await state.watchChanges(at: "/note.txt", recursive: false) + let task = startRecording(stream, into: recorder) + + try await state.writeFile("/note.txt", content: "one") + _ = try await waitForRecordedEvents(1, recorder: recorder) + + task.cancel() + } + + try await Task.sleep(for: .milliseconds(50)) + try await state.writeFile("/note.txt", content: "two") + + let settled = try await waitForSettledEvents(recorder: recorder) + #expect(settled == [ChangeEvent(kind: .created, path: "/note.txt", nodeKind: .file)]) + } + @Test func `nested Codable mutation metadata roundtrips`() throws { let original = MutationMode.execution @@ -267,7 +667,7 @@ struct WorkspaceTests { #expect(decoded == original) } - @Test + @Test(.tags(.replacement)) func `replacement request and result roundtrip through Codable`() throws { let request = ReplacementRequest( scope: "/Sources", @@ -334,6 +734,13 @@ struct WorkspaceTests { let filesystem = MinimalFilesystem() try await filesystem.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) + do { + try await filesystem.configure(rootDirectory: URL(fileURLWithPath: "/ignored")) + Issue.record("expected default configure error") + } catch let error as WorkspaceError { + #expect(error.description.contains("not configured")) + } + do { try await filesystem.createSymlink(path: "/alias.txt", target: "note.txt") Issue.record("expected unsupported createSymlink error") @@ -384,7 +791,7 @@ struct WorkspaceTests { } } - @Test + @Test(.tags(.tree)) func `walkTree and summarizeTree preserve stable ordering`() async throws { let fs = InMemoryFilesystem() try await fs.createDirectory(path: "/src", recursive: true) @@ -402,7 +809,7 @@ struct WorkspaceTests { #expect(summary.directoryCount == 2) } - @Test + @Test(.tags(.tree)) func `walkTree and summarizeTree report symlink kinds`() async throws { let fs = InMemoryFilesystem() try await fs.writeFile(path: "/note.txt", data: Data("hello".utf8), append: false) @@ -419,7 +826,7 @@ struct WorkspaceTests { #expect(summary.symlinkCount == 1) } - @Test + @Test(.tags(.replacement)) func `previewReplacement previews without mutating files`() async throws { let fs = InMemoryFilesystem() try await fs.createDirectory(path: "/src", recursive: true) @@ -438,7 +845,7 @@ struct WorkspaceTests { #expect(try await state.readFile("/src/a.txt") == "foo") } - @Test + @Test(.tags(.replacement)) func `applyReplacement rolls back on write failure`() async throws { let base = InMemoryFilesystem() try await base.createDirectory(path: "/src", recursive: true) @@ -461,7 +868,7 @@ struct WorkspaceTests { #expect(try await base.readFile(path: "/src/b.txt") == Data("foo".utf8)) } - @Test + @Test(.tags(.replacement)) func `applyReplacement best effort reports failures without rollback`() async throws { let base = InMemoryFilesystem() try await base.createDirectory(path: "/src", recursive: true) @@ -485,7 +892,7 @@ struct WorkspaceTests { #expect(try await base.readFile(path: "/src/b.txt") == Data("foo".utf8)) } - @Test + @Test(.tags(.replacement)) func `applyReplacement fail-fast stops after the first failure`() async throws { let base = InMemoryFilesystem() try await base.createDirectory(path: "/src", recursive: true) @@ -512,7 +919,7 @@ struct WorkspaceTests { #expect(try await base.readFile(path: "/src/c.txt") == Data("foo".utf8)) } - @Test + @Test(.tags(.replacement)) func `applyReplacement returns an empty execution result when nothing matches`() async throws { let state = Workspace(filesystem: InMemoryFilesystem()) @@ -527,7 +934,7 @@ struct WorkspaceTests { #expect(!result.rolledBack) } - @Test + @Test(.tags(.replacement)) func `previewReplacement respects scope excludes and case-insensitive matching`() async throws { let fs = InMemoryFilesystem() try await fs.createDirectory(path: "/src/dir", recursive: true) @@ -552,7 +959,7 @@ struct WorkspaceTests { #expect(addedLineTexts(result.changes.first?.diff) == ["bar"]) } - @Test + @Test(.tags(.replacement)) func `previewReplacement rejects invalid UTF-8 and empty search patterns`() async throws { let fs = InMemoryFilesystem() try await fs.writeFile(path: "/binary.bin", data: Data([0xFF]), append: false) @@ -599,7 +1006,7 @@ struct WorkspaceTests { } } - @Test + @Test(.tags(.edits)) func `applyEdits succeeds across multiple files`() async throws { let fs = InMemoryFilesystem() let state = Workspace(filesystem: fs) @@ -624,7 +1031,7 @@ struct WorkspaceTests { #expect(try await state.readFile("/src/c.txt") == "one two") } - @Test + @Test(.tags(.edits)) func `applyEdits rolls back on failure`() async throws { let base = InMemoryFilesystem() try await base.writeFile(path: "/a.txt", data: Data("old".utf8), append: false) @@ -647,7 +1054,7 @@ struct WorkspaceTests { #expect(!bExists) } - @Test + @Test(.tags(.edits)) func `previewEdits reports change states and delete diffs`() async throws { let fs = InMemoryFilesystem() try await fs.createDirectory(path: "/dir", recursive: true) @@ -674,7 +1081,7 @@ struct WorkspaceTests { #expect(result.edits[2].fileChanges.isEmpty) } - @Test + @Test(.tags(.edits)) func `applyEdits fail-fast keeps prior changes and reports non-workspace errors`() async throws { let base = InMemoryFilesystem() try await base.writeFile(path: "/old.txt", data: Data("gone".utf8), append: false) @@ -698,7 +1105,7 @@ struct WorkspaceTests { #expect(!(await base.exists(path: "/after.txt"))) } - @Test + @Test(.tags(.edits)) func `applyEdits returns an empty execution result when given no edits`() async throws { let state = Workspace(filesystem: InMemoryFilesystem()) let result = try await state.applyEdits([]) @@ -710,7 +1117,7 @@ struct WorkspaceTests { #expect(!result.rolledBack) } - @Test + @Test(.tags(.edits)) func `previewEdits plans sequential text diffs across earlier edits`() async throws { let state = Workspace(filesystem: InMemoryFilesystem()) @@ -725,7 +1132,7 @@ struct WorkspaceTests { #expect(addedLineTexts(result.edits[1].fileChanges.first?.diff) == ["one two"]) } - @Test + @Test(.tags(.edits)) func `previewEdits expands recursive file changes and omits binary diffs`() async throws { let fs = InMemoryFilesystem() try await fs.createDirectory(path: "/src/nested", recursive: true) @@ -748,6 +1155,19 @@ struct WorkspaceTests { #expect(deletePreview.edits[0].fileChanges.last?.diff == nil) } + @Test(.tags(.edits)) + func `previewEdits preserves trailing newline metadata in diffs`() async throws { + let state = Workspace(filesystem: InMemoryFilesystem()) + + let result = try await state.previewEdits([ + .writeFile(path: "/multi.txt", content: "a\nb\n"), + ]) + + let lines = diffLines(result.edits[0].fileChanges.first?.diff) + #expect(lines.map(\.text) == ["a", "b"]) + #expect(lines.allSatisfy { $0.hasTrailingNewline }) + } + @Test func `workspace path and type helpers cover normalization and coding`() throws { #expect(WorkspacePath(normalizing: "", relativeTo: "/base") == "/base") @@ -774,7 +1194,7 @@ struct WorkspaceTests { #expect(fileInfo.path == "/file.txt") } - @Test + @Test(.tags(.edits)) func `applyEdits works with overlay and mountable filesystems`() async throws { let workspaceRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceMountRoot") defer { WorkspaceTestSupport.removeDirectory(workspaceRoot) } From f5cf253dc95fd5ee9d936b1d4e13b7ef850594a4 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 19:19:57 -0700 Subject: [PATCH 2/5] Added a test action --- .github/workflows/pr-tests.yml | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/pr-tests.yml diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 0000000..3b6d5f7 --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,129 @@ +name: PR Tests + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + swift-tests: + name: Swift Tests + runs-on: macos-latest + timeout-minutes: 15 + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.2" + + - name: Show Swift version + run: swift --version + + - name: Run test suite + shell: bash + run: | + mkdir -p .build/test-results + swift test \ + --disable-xctest \ + --enable-swift-testing \ + --enable-code-coverage \ + --xunit-output .build/test-results/xunit.xml + + - name: Publish test summary + if: always() + shell: bash + run: | + python3 <<'PY' + import json + import os + import pathlib + import xml.etree.ElementTree as ET + + summary_path = pathlib.Path(os.environ["GITHUB_STEP_SUMMARY"]) + report_path = pathlib.Path(".build/test-results/xunit.xml") + lines = ["## Test Results", ""] + + if report_path.exists(): + root = ET.parse(report_path).getroot() + suites = root.findall("testsuite") + if not suites and root.tag == "testsuite": + suites = [root] + + tests = sum(int(suite.attrib.get("tests", 0)) for suite in suites) + failures = sum(int(suite.attrib.get("failures", 0)) for suite in suites) + errors = sum(int(suite.attrib.get("errors", 0)) for suite in suites) + skipped = sum(int(suite.attrib.get("skipped", 0)) for suite in suites) + duration = sum(float(suite.attrib.get("time", 0.0)) for suite in suites) + + lines.extend( + [ + f"- Tests: {tests}", + f"- Failures: {failures}", + f"- Errors: {errors}", + f"- Skipped: {skipped}", + f"- Duration: {duration:.3f}s", + ] + ) + + failed_tests = [] + for case in root.iter("testcase"): + if case.find("failure") is None and case.find("error") is None: + continue + + classname = case.attrib.get("classname", "").strip() + name = case.attrib.get("name", "unknown").strip() + label = f"{classname}.{name}" if classname else name + failed_tests.append(label) + + if failed_tests: + lines.extend(["", "### Failed Tests", ""]) + lines.extend(f"- `{label}`" for label in failed_tests[:20]) + if len(failed_tests) > 20: + lines.append(f"- ...and {len(failed_tests) - 20} more") + else: + lines.append("- No xUnit report was generated.") + + coverage_files = sorted(pathlib.Path(".build").glob("**/debug/codecov/*.json")) + if coverage_files: + coverage = json.loads(coverage_files[0].read_text(encoding="utf-8")) + totals = coverage.get("data", [{}])[0].get("totals", {}).get("lines", {}) + count = totals.get("count") + covered = totals.get("covered") + percent = totals.get("percent") + + if count is not None and covered is not None and percent is not None: + lines.extend( + [ + "", + "## Coverage", + "", + f"- Lines: {percent:.2f}% ({covered}/{count})", + ] + ) + + summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + PY + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-test-results-${{ github.event.pull_request.number }} + if-no-files-found: warn + path: | + .build/test-results/xunit.xml + .build/**/debug/codecov/*.json From 6237e7111588cd2f0e064087eae6cef604de4db8 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 19:25:50 -0700 Subject: [PATCH 3/5] Possible test artifact upload fix --- .github/workflows/pr-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 3b6d5f7..251266f 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -124,6 +124,7 @@ jobs: with: name: pr-test-results-${{ github.event.pull_request.number }} if-no-files-found: warn + include-hidden-files: true path: | .build/test-results/xunit.xml .build/**/debug/codecov/*.json From ec5ec35efa2133a2d0e9636e83e5cbb45c5559d0 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 19:38:37 -0700 Subject: [PATCH 4/5] Updated test uploading --- .github/workflows/pr-tests.yml | 97 ++++++++++++---------------------- 1 file changed, 34 insertions(+), 63 deletions(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 251266f..073772c 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -19,6 +19,8 @@ jobs: timeout-minutes: 15 permissions: + actions: read + checks: write contents: read steps: @@ -43,7 +45,20 @@ jobs: --enable-code-coverage \ --xunit-output .build/test-results/xunit.xml - - name: Publish test summary + - name: Publish test report + if: ${{ !cancelled() }} + uses: dorny/test-reporter@v2 + with: + name: Swift Test Report + path: .build/test-results/xunit.xml + reporter: swift-xunit + fail-on-error: false + use-actions-summary: true + badge-title: swift tests + list-suites: failed + list-tests: failed + + - name: Publish coverage summary if: always() shell: bash run: | @@ -51,76 +66,32 @@ jobs: import json import os import pathlib - import xml.etree.ElementTree as ET summary_path = pathlib.Path(os.environ["GITHUB_STEP_SUMMARY"]) - report_path = pathlib.Path(".build/test-results/xunit.xml") - lines = ["## Test Results", ""] - - if report_path.exists(): - root = ET.parse(report_path).getroot() - suites = root.findall("testsuite") - if not suites and root.tag == "testsuite": - suites = [root] - - tests = sum(int(suite.attrib.get("tests", 0)) for suite in suites) - failures = sum(int(suite.attrib.get("failures", 0)) for suite in suites) - errors = sum(int(suite.attrib.get("errors", 0)) for suite in suites) - skipped = sum(int(suite.attrib.get("skipped", 0)) for suite in suites) - duration = sum(float(suite.attrib.get("time", 0.0)) for suite in suites) - - lines.extend( - [ - f"- Tests: {tests}", - f"- Failures: {failures}", - f"- Errors: {errors}", - f"- Skipped: {skipped}", - f"- Duration: {duration:.3f}s", - ] - ) - - failed_tests = [] - for case in root.iter("testcase"): - if case.find("failure") is None and case.find("error") is None: - continue - - classname = case.attrib.get("classname", "").strip() - name = case.attrib.get("name", "unknown").strip() - label = f"{classname}.{name}" if classname else name - failed_tests.append(label) - - if failed_tests: - lines.extend(["", "### Failed Tests", ""]) - lines.extend(f"- `{label}`" for label in failed_tests[:20]) - if len(failed_tests) > 20: - lines.append(f"- ...and {len(failed_tests) - 20} more") - else: - lines.append("- No xUnit report was generated.") - coverage_files = sorted(pathlib.Path(".build").glob("**/debug/codecov/*.json")) - if coverage_files: - coverage = json.loads(coverage_files[0].read_text(encoding="utf-8")) - totals = coverage.get("data", [{}])[0].get("totals", {}).get("lines", {}) - count = totals.get("count") - covered = totals.get("covered") - percent = totals.get("percent") - - if count is not None and covered is not None and percent is not None: - lines.extend( - [ - "", - "## Coverage", - "", - f"- Lines: {percent:.2f}% ({covered}/{count})", - ] - ) - summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + if not coverage_files: + with summary_path.open("a", encoding="utf-8") as handle: + handle.write("\n## Coverage\n\n- No coverage report was generated.\n") + raise SystemExit(0) + + coverage = json.loads(coverage_files[0].read_text(encoding="utf-8")) + totals = coverage.get("data", [{}])[0].get("totals", {}).get("lines", {}) + count = totals.get("count") + covered = totals.get("covered") + percent = totals.get("percent") + + with summary_path.open("a", encoding="utf-8") as handle: + handle.write("\n## Coverage\n\n") + if count is None or covered is None or percent is None: + handle.write("- Coverage totals were unavailable.\n") + else: + handle.write(f"- Lines: {percent:.2f}% ({covered}/{count})\n") PY - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: pr-test-results-${{ github.event.pull_request.number }} if-no-files-found: warn From 37fbe658e71f3cf2738f238fca7505cf828bbcc7 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 22 Mar 2026 20:14:45 -0700 Subject: [PATCH 5/5] Updated versions --- .github/workflows/pr-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 073772c..712af8b 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Swift - uses: swift-actions/setup-swift@v2 + uses: swift-actions/setup-swift@v3 with: swift-version: "6.2" @@ -47,7 +47,7 @@ jobs: - name: Publish test report if: ${{ !cancelled() }} - uses: dorny/test-reporter@v2 + uses: dorny/test-reporter@v3 with: name: Swift Test Report path: .build/test-results/xunit.xml