Reuse remote copy planning and trim copy-only tests

This commit is contained in:
Kaede Akino committed 2026-09-16 22:35:49 +08:00
1 parent e0377d6c09
commit e54f5c81c9
14 files changed
+137 -299

No files matched your search

@@ -11,12 +11,6 @@ struct CommandRiskCase: Sendable, CustomTestStringConvertible {
@Suite
struct CommandRiskClassifierTests {
@Test
func cautionLabelAlsoFitsUnknownCommands() {
#expect(CommandRisk.caution.label == String(localized: "Caution"))
#expect(CommandRiskClassifier.assess("vendorctl inspect").risk == .caution)
}
@Test(
arguments: [
CommandRiskCase(command: "ls -la", expected: .safe),
@@ -11,7 +11,7 @@ struct WorkspaceUploadNoticeTests {
hasTransferConfiguration: false,
isWorkingDirectoryPending: true,
workingDirectory: nil
) == String(localized: "The SSH session is not ready for file transfer.")
) != nil
)
#expect(
TerminalUploadAvailability.problem(
@@ -19,7 +19,7 @@ struct WorkspaceUploadNoticeTests {
hasTransferConfiguration: true,
isWorkingDirectoryPending: true,
workingDirectory: nil
) == String(localized: "Wait for the shell prompt to report its current directory.")
) != nil
)
#expect(
TerminalUploadAvailability.problem(
@@ -31,10 +31,8 @@ struct WorkspaceUploadNoticeTests {
)
}
@Test func uploadNoticePresentationUsesOneCrossPlatformMapping() {
@Test func uploadNoticeRetainsItsDestinationAndRejectionState() {
let queued = WorkspaceUploadNotice.queued(2, "/srv/project")
#expect(queued.symbol == "checkmark.circle.fill")
#expect(queued.title == String(localized: "Added 2 items to Transfers"))
#expect(queued.detail == "/srv/project")
#expect(!queued.isRejected)
#expect(WorkspaceUploadNotice.rejected("Denied").isRejected)
@@ -256,6 +256,8 @@ public nonisolated enum SFTPRemoteCopyPlanner {
)
}
/// Returns whether resolving the batch changed remote directories, even without file jobs.
@discardableResult
@MainActor public static func enqueue(
_ batch: SFTPRemoteCopyBatch,
policy: TransferConflictPolicy,
@@ -265,7 +267,7 @@ public nonisolated enum SFTPRemoteCopyPlanner {
client: any SFTPClientProtocol,
coordinator: SFTPTransferCoordinator,
completion: ((SFTPTransfer.State) -> Void)? = nil
) async throws {
) async throws -> Bool {
let resolution = try await resolveDirectories(
batch.directories, conflicts: batch.directoryConflicts,
policy: policy, client: client
@@ -304,6 +306,7 @@ public nonisolated enum SFTPRemoteCopyPlanner {
completion: completion
)
}
return resolution.changed
}
private struct Preflight {
@@ -317,6 +320,7 @@ public nonisolated enum SFTPRemoteCopyPlanner {
var skipped: [String] = []
var mappings: [(String, String)] = []
var created: [String] = []
var changed = false
}
private static func preflight(
@@ -367,6 +371,7 @@ public nonisolated enum SFTPRemoteCopyPlanner {
continue
case .replace:
try await client.removeFile(at: original)
result.changed = true
case .keepBoth:
destination = try await availablePath(for: original, client: client)
result.mappings.append((original, destination))
@@ -375,6 +380,7 @@ public nonisolated enum SFTPRemoteCopyPlanner {
if try await client.attributes(at: destination) == nil {
try await client.createDirectory(at: destination)
result.created.append(destination)
result.changed = true
}
}
return result
+6 -4
View File
@@ -173,10 +173,12 @@ background expiration pauses uploads/downloads and cancels remote copies; borrow
session retirement still cancels its transfers. macOS termination gives asynchronous
cleanup at most three seconds, so an unreachable server cannot hold up quitting.
Nothing is silently replayed.
Desktop and mobile upload/download preparation and enqueueing use the Remote
Infrastructure planners. Download batches retain their destination independently of
file jobs, so directory-only selections still create their empty directory trees.
App sessions retain pending-batch presentation, cancellation, and completion handling.
Desktop and mobile upload/download and remote-copy preparation and enqueueing use
Remote Infrastructure planners. Download batches retain their destination independently
of file jobs, so directory-only selections still create their empty directory trees.
Remote-copy enqueueing reports directory mutations so desktop refreshes also cover
copies without file jobs. App sessions retain pending-batch presentation, cancellation,
error handling, and completion refresh; interactive browser deletion stays app-owned.
`sftp_copy` is Termind-owned and acting-only in Assistant, with Ask/sequential
policy. It copies regular files and directory trees into an existing directory;
+13
View File
@@ -105,3 +105,16 @@ Verification completed on 2026-09-16:
- The full pinned TFM test action built successfully and passed 1,337 of 1,343 tests. Four failures also reproduce with the pre-refactor production source: `SettingsSyncTests.providedDatabaseCopyMigratesWithoutReset` (expects schema 15, current schema is 17), `ModelCapabilityResolverTests.reportedFieldsWinOverCodexAutoReviewOverride`, `ScrubberWebClientTests.fetchPreflightNeverEscalatesPrivateHTTP`, and `ChatComposerStateTests.aFailureNoticeIsNotAnAnswerWorthNaming`. `MetricsJourneyTests.offlineRestoredMetricsReconcileOnMainActor` passed on rerun. `SSHImportTests.indexedIdentityPlanningHandlesTheImportFileLimit` exceeded its two-second wall-clock limit when run concurrently, but passed in isolation against both versions (0.100 seconds with the final source).
- The full pinned TFI test action on iPhone 17 Pro / iOS 26.5 built successfully and passed 426 of 432 tests, including all 205 framework tests. The unchanged mobile graph retained five failures on isolated rerun: `MobileVaultFeatureTests.importRequiresAnExplicitUsernameWhenTheConfigurationOmitsIt` and four `MobileAssistantTests` cases (`completeRegistryIncludesRemoteAndBuiltInToolsOnlyForCapableModels`, `firstNewConversationSendPromotesDraftAndStartsRun`, `attachmentOnlyFirstSendPromotesTheConversation`, and `existingConversationSendStillUsesTheDraftExecutionPath`). `MobileEditorToolbarTests.iconButtonsKeepTheirNamesAndSaveLifecycle` passed on rerun.
- Strict Swift formatting across all production/test targets, `Scripts/check-zh-localization.sh`, the removed-symbol reference scan, and `git diff --check` passed. Both full test actions retained `-onlyUsePackageVersionsFromResolvedFile`; no dependency, localization, or shared production source changed.
## Follow-up Reuse and Test Cleanup (2026-09-16)
- **Reuse the shared remote-copy planner — implemented; full verification remains partial.** Evidence: `rg -n 'PreparedRemoteCopy|pendingRemoteCopyBatch|SFTPRemoteCopyPlanner.enqueue|resolveRemoteDirectories' TFM TFI Frameworks TFMTests TFITests -g '*.swift'` confirms the desktop-only batch types and duplicated preparation, conflict resolution, and enqueue loops in `SFTPSessionModel`; mobile already uses `SFTPRemoteCopyPlanner`. Replaced the desktop path with that planner and returned directory-mutation information so empty-directory copies still refresh the browser. Retained app-owned pending state, error handling, transfer completion refresh, and the interactive deletion lifecycle. Estimated production reduction: 220 lines; actual reduction: 193 lines. Added behavioral coverage for file/directory and subtree conflicts across all three policies, empty-directory refresh, and pending-copy cancellation; retained the planner's cancellation and validated recursive deletion checks. Upload conflict helpers remain a separate candidate rather than widening this change.
- **Remove copy-only and duplicated test expectations — implemented; full verification remains partial.** Evidence: `rg -n 'roleLabelsMatchDesktopDefaults|cautionLabelAlsoFitsUnknownCommands|copyApprovalLabelsAndOverwriteWarningUseAppResources|approvalDetailsLocalizeBothEndpointsAndDestructiveWarning|handshakeFailureDescriptionsDistinguishBannerConditions|uploadNoticePresentationUsesOneCrossPlatformMapping' FrameworkTests TFMTests TFITests -g '*.swift'` identifies role-label snapshots, a duplicate unknown-command case, repeated cross-platform approval copy checks, and mixed behavior/copy tests. Removed pure copy assertions and duplicate cases, retained unknown-command classification in its parameterized suite, and consolidated approval endpoint values and destructive-warning policy coverage across policies/locales. Preserved diagnostic context, upload gating, and notice payload/state coverage. Also dropped isolated fixed title/icon assertions from otherwise behavioral tests. Estimated test reduction: 50 lines; actual cleanup reduction: 59 lines, excluding 75 net lines of remote-copy regression coverage. Total Swift reduction: 177 lines. Retained architecture guards, language-selection/catalog validation, protocol strings, user-data round trips, and behavioral assertions that contain strings.
Verification and remaining limitations (2026-09-16):
- The pinned TFM focused test action passed all 200 tests, including SFTP/session/copy, architecture boundaries, tool presentation, handshake diagnostics, SSH import, toolbar behavior, app language, upload availability, and command classification. Result: `/tmp/termind-refactor-copy-focused-20260916.xcresult`.
- The pinned full TFM test action built successfully and passed 1,436 of 1,437 tests. The sole failure is the unchanged `SpeechWaveformViewTests.waveformRespondsToLevelsAndReduceMotionMakesItStable` bitmap equality assertion. An earlier backlog entry records this same test failure; no baseline or isolated rerun was performed in this pass. Result: `/tmp/termind-refactor-copy-macos-20260916.xcresult`.
- The full TFI action did not build or execute tests: the supplied simulator destination UUID was unavailable. This is not a passing iOS verification. A fresh device listing found an available booted iPhone 17 Pro, but the owner requested an immediate commit before rerunning either platform.
- Strict Swift formatting across all production/test targets, `Scripts/check-zh-localization.sh`, the removed desktop-helper reference scan, and `git diff --check` passed. Xcode successfully resolved modules that standalone SourceKit could not find. Existing generic SwiftLint file/type/function-size and complexity findings remain outside this independent refactor; no inline suppressions were added. Dependencies and localization catalogs are unchanged.
- Submitted at the owner's explicit request with these verification limitations retained rather than deleting unrelated behavioral tests to obtain a green result.
-5
View File
@@ -8,11 +8,6 @@ import Testing
@Suite @MainActor
struct MobileAISettingsTests {
@Test func roleLabelsMatchDesktopDefaults() {
#expect(AIRole.chat.unsetLabel == AIRole.guardian.unsetLabel)
#expect(AIRole.commandGenerator.unsetLabel != AIRole.guardian.unsetLabel)
}
private func provider() -> ModelProvider {
var value = ModelProvider(name: "Gateway", inferenceURL: "https://example.invalid/v1/responses")
value.models = [AIModel(id: "one"), AIModel(id: "two")]
@@ -27,28 +27,6 @@ struct MobileSFTPCopyAvailabilityTests {
#expect(registry.available(in: .reviewing).descriptors.isEmpty)
#expect(TermindAgentTools.presenter(for: descriptor)?.id == "builtin.sftp_copy")
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func copyApprovalLabelsAndOverwriteWarningUseAppResources(_ language: String) {
let items = SFTPCopyTool.presentArguments(
.init(
arguments: [
"source_host_id": .string("source"), "destination_host_id": .string("destination"),
"conflict_policy": .string("replace"),
], locale: Locale(identifier: language)
))
let label = language == "en" ? "Source host" : language == "zh-Hant" ? "來源主機" : "源主机"
#expect(
items.contains {
if case .field(let field) = $0 { return field.label == label }
return false
})
#expect(
items.contains {
if case .message(_, .warning) = $0 { return true }
return false
})
}
}
@MainActor
+12 -211
View File
@@ -86,30 +86,6 @@ private struct SFTPDirectoryPresentation {
@Observable
final class SFTPSessionModel: Identifiable {
struct PreparedRemoteCopyJob {
var sourcePath: String
var destinationPath: String
var byteCount: Int64
var hasConflict: Bool
var conflictingTargetIsDirectory: Bool
}
struct PreparedRemoteCopyBatch {
var id = UUID()
var source: SFTPRemoteCopySource
var jobs: [PreparedRemoteCopyJob]
var remoteDirectories: [String]
var remoteDirectoryConflicts: Set<String>
var conflictCount: Int
}
private struct RemoteConflictPreflight {
var directories: [String]
var directoryConflicts: Set<String>
var targetKinds: [Bool?]
var conflictCount: Int
}
let id = UUID()
let hostID: Host.ID
let initialPath: String
@@ -169,7 +145,7 @@ final class SFTPSessionModel: Identifiable {
var connectionJourney: ConnectionJourney { connection.journey }
private var pendingDownloadBatch: SFTPRemoteDownloadBatch?
var pendingUploadBatch: SFTPLocalUploadBatch?
var pendingRemoteCopyBatch: PreparedRemoteCopyBatch?
var pendingRemoteCopyBatch: SFTPRemoteCopyBatch?
var operationError: String?
private(set) var canCancelOperation = false
@@ -853,77 +829,16 @@ final class SFTPSessionModel: Identifiable {
guard let client else { return }
do {
pendingUploadBatch = nil
let manifest = try await coordinator.remoteManifest(for: payload)
var jobs: [PreparedRemoteCopyJob] = []
var directories: [String] = []
for entry in manifest.entries {
let destination = RemotePath.join(path, entry.relativePath)
if entry.isDirectory {
directories.append(destination)
} else {
jobs.append(
PreparedRemoteCopyJob(
sourcePath: entry.sourcePath,
destinationPath: destination,
byteCount: entry.byteCount,
hasConflict: false,
conflictingTargetIsDirectory: false
))
}
}
let preflight = try await remoteConflictPreflight(
directories: directories, targets: jobs.map(\.destinationPath), client: client
let batch = try await SFTPRemoteCopyPlanner.prepare(
payload, destination: path, client: client, coordinator: coordinator
)
for (index, kind) in zip(jobs.indices, preflight.targetKinds) {
guard let kind else { continue }
jobs[index].hasConflict = true
jobs[index].conflictingTargetIsDirectory = kind
}
pendingDownloadBatch = nil
pendingRemoteCopyBatch = PreparedRemoteCopyBatch(
source: manifest.source, jobs: jobs,
remoteDirectories: preflight.directories,
remoteDirectoryConflicts: preflight.directoryConflicts,
conflictCount: preflight.conflictCount
)
pendingRemoteCopyBatch = batch
} catch {
operationError = error.localizedDescription
}
}
private func remoteConflictPreflight(
directories: [String], targets: [String], client: any SFTPClientProtocol
) async throws -> RemoteConflictPreflight {
let directories = directories.sorted { $0.count < $1.count }
var directoryConflicts: Set<String> = []
var targetKinds: [Bool?] = []
var conflictCount = 0
for directory in directories {
guard !directoryConflicts.contains(where: { directory.hasPrefix($0 + "/") })
else { continue }
if let existing = try await client.attributes(at: directory), !existing.isDirectory {
directoryConflicts.insert(directory)
conflictCount += 1
}
}
for target in targets {
guard !directoryConflicts.contains(where: { target.hasPrefix($0 + "/") }) else {
targetKinds.append(nil)
continue
}
let kind = try await client.attributes(at: target)?.isDirectory
targetKinds.append(kind)
if kind != nil { conflictCount += 1 }
}
return RemoteConflictPreflight(
directories: directories, directoryConflicts: directoryConflicts,
targetKinds: targetKinds, conflictCount: conflictCount
)
}
func open(
_ file: RemoteFile, coordinator: SFTPTransferCoordinator,
completion: @escaping (URL) -> Void
@@ -1051,55 +966,15 @@ final class SFTPSessionModel: Identifiable {
else { return }
pendingRemoteCopyBatch = nil
do {
let remoteResolution = try await resolveRemoteDirectories(
batch.remoteDirectories, conflicts: batch.remoteDirectoryConflicts,
policy: policy, client: client
)
for var job in batch.jobs {
if remoteResolution.skipped.contains(where: {
job.destinationPath == $0 || job.destinationPath.hasPrefix($0 + "/")
}) {
continue
}
for mapping in remoteResolution.mappings
where job.destinationPath == mapping.0
|| job.destinationPath.hasPrefix(mapping.0 + "/")
{
job.destinationPath =
mapping.1
+ job.destinationPath.dropFirst(mapping.0.count)
job.hasConflict = false
}
if job.hasConflict {
guard
let resolved = try await resolveRemoteConflict(
path: job.destinationPath,
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
policy: policy, client: client
)
else { continue }
job.destinationPath = resolved.path
job.hasConflict = resolved.overwrite
}
coordinator.enqueueRemoteCopy(
batchID: batch.id,
sourcePath: job.sourcePath,
sourceHostID: batch.source.hostID,
sourceConnection: batch.source.connection,
destinationPath: job.destinationPath,
destinationHostID: hostID,
destinationConnection: destinationConnection,
byteCount: job.byteCount,
overwrite: job.hasConflict && policy == .replace,
sourceHomeDirectory: batch.source.homeDirectory,
destinationHomeDirectory: homeResolution.resolvedDirectory
) { [weak self] state in
if state == .completed { self?.scheduleSilentRefresh() }
}
let changedDirectories = try await SFTPRemoteCopyPlanner.enqueue(
batch, policy: policy, destinationHostID: hostID,
destinationConnection: destinationConnection,
destinationHomeDirectory: homeResolution.resolvedDirectory,
client: client, coordinator: coordinator
) { [weak self] state in
if state == .completed { self?.scheduleSilentRefresh() }
}
if remoteResolution.changed { scheduleSilentRefresh() }
if changedDirectories { scheduleSilentRefresh() }
} catch {
operationError = error.localizedDescription
}
@@ -1203,80 +1078,6 @@ final class SFTPSessionModel: Identifiable {
}
}
private func availableRemotePath(
for path: String, client: any SFTPClientProtocol
) async throws -> String {
var index = 2
var candidate = RemotePath.keepBothCandidate(for: path, index: index)
while try await client.attributes(at: candidate) != nil {
index += 1
candidate = RemotePath.keepBothCandidate(for: path, index: index)
}
return candidate
}
private struct RemoteDirectoryResolution {
var skipped: [String] = []
var mappings: [(String, String)] = []
var changed = false
}
private func resolveRemoteConflict(
path: String, conflictingTargetIsDirectory: Bool,
policy: TransferConflictPolicy, client: any SFTPClientProtocol
) async throws -> (path: String, overwrite: Bool)? {
switch policy {
case .skip:
return nil
case .replace:
if conflictingTargetIsDirectory,
let existing = try await client.attributes(at: path)
{
try await deleteRecursively(existing, client: client)
}
return (path, true)
case .keepBoth:
return (try await availableRemotePath(for: path, client: client), false)
}
}
private func resolveRemoteDirectories(
_ directories: [String], conflicts: Set<String>,
policy: TransferConflictPolicy, client: any SFTPClientProtocol
) async throws -> RemoteDirectoryResolution {
var result = RemoteDirectoryResolution()
for original in directories {
if result.skipped.contains(where: {
original == $0 || original.hasPrefix($0 + "/")
}) {
continue
}
var destination = original
for mapping in result.mappings
where destination == mapping.0 || destination.hasPrefix(mapping.0 + "/") {
destination = mapping.1 + destination.dropFirst(mapping.0.count)
}
if conflicts.contains(original) {
switch policy {
case .skip:
result.skipped.append(original)
continue
case .replace:
try await client.removeFile(at: original)
result.changed = true
case .keepBoth:
destination = try await availableRemotePath(for: original, client: client)
result.mappings.append((original, destination))
}
}
if try await client.attributes(at: destination) == nil {
try await client.createDirectory(at: destination)
result.changed = true
}
}
return result
}
}
// MARK: - Demo data
-11
View File
@@ -54,18 +54,8 @@ struct AgentToolPresentationTests {
locale: Locale(identifier: "en")
)
#expect(presentation.symbol == "terminal")
#expect(presentation.title == "Run the SSH command on Production")
#expect(!presentation.title.contains(command))
#expect(!presentation.title.contains(hostID.uuidString))
#expect(
AgentToolPresentation.approval(
for: request,
presenter: SSHExecTool.presenter,
hostName: "Production",
locale: Locale(identifier: "zh-Hans")
).title == "在Production上运行SSH命令"
)
#expect(
presentation.detail.items == [
.text(.init(title: "Command", text: command, style: .monospaced)),
@@ -97,7 +87,6 @@ struct AgentToolPresentationTests {
let presentation = AgentToolPresentation.approval(
for: request, locale: Locale(identifier: "en")
)
#expect(presentation.title == "Run remote tool")
#expect(!presentation.title.contains("hunter2"))
#expect(
presentation.detail.items == [
+8 -8
View File
@@ -279,25 +279,25 @@ struct SFTPCopyToolTests {
#expect(await fixture.backend.opens == 0)
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func approvalDetailsLocalizeBothEndpointsAndDestructiveWarning(_ language: String) throws {
@Test(arguments: ["skip", "replace", "keep_both"], ["en", "zh-Hans", "zh-Hant"])
func approvalDetailsRetainEndpointsAndWarnOnlyForReplacement(_ policy: String, _ language: String) throws {
let fixture = CopyFixture()
let items = SFTPCopyTool.presentArguments(
.init(
arguments: try #require(fixture.invocation(policy: "replace").call.arguments.objectValue),
arguments: try #require(fixture.invocation(policy: policy).call.arguments.objectValue),
locale: Locale(identifier: language)
))
let labels = items.compactMap { item -> String? in
if case .field(let field) = item { return field.label }
let values = items.compactMap { item -> String? in
if case .field(let field) = item { return field.value }
return nil
}
#expect(labels.contains(language == "en" ? "Source host" : language == "zh-Hant" ? "來源主機" : "源主机"))
#expect(labels.contains(language == "en" ? "Destination host" : language == "zh-Hant" ? "目標主機" : "目标主机"))
#expect(values.contains(fixture.sourceID.uuidString))
#expect(values.contains(fixture.destinationID.uuidString))
#expect(
items.contains {
if case .message(_, .warning) = $0 { return true }
return false
})
} == (policy == "replace"))
}
}
+87 -12
View File
@@ -899,8 +899,10 @@ struct SFTPFeatureTests {
#expect(try root.appending(path: "bundle/empty").resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true)
}
@Test
func remoteCopyPreflightFindsTargetConflicts() async {
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth], [RemoteFile.Kind.file, .directory])
func remoteCopyResolvesExistingFilesAndDirectories(
policy: TransferConflictPolicy, kind: RemoteFile.Kind
) async throws {
let sourceHostID = UUID()
let targetHostID = UUID()
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
@@ -908,7 +910,7 @@ struct SFTPFeatureTests {
let sourceClient = TreeSFTPClient(listings: [bundle.path: [readme]])
let targetClient = ManifestSFTPClient(entries: [
"/incoming/bundle/README.md": remoteFile(
path: "/incoming/bundle/README.md", kind: .file, size: 3
path: "/incoming/bundle/README.md", kind: kind, size: 3
)
])
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { sourceClient }
@@ -925,10 +927,78 @@ struct SFTPFeatureTests {
await target.prepareRemoteCopy(payload, coordinator: coordinator)
#expect(target.pendingRemoteCopyBatch?.remoteDirectories == ["/incoming/bundle"])
#expect(target.pendingRemoteCopyBatch?.jobs.first?.destinationPath == "/incoming/bundle/README.md")
#expect(target.pendingRemoteCopyBatch?.jobs.first?.hasConflict == true)
#expect(target.pendingRemoteCopyBatch?.directoryCount == 1)
#expect(target.pendingRemoteCopyBatch?.transferCount == 1)
#expect(target.conflictCount == 1)
await target.enqueuePendingRemoteCopy(policy: policy, coordinator: coordinator)
#expect(!target.hasPendingBatch)
#expect(target.operationError == nil)
let name = policy == .keepBoth ? "README.2.md" : "README.md"
#expect(coordinator.sftpTransfers.map(\.remotePath) == (policy == .skip ? [] : ["/incoming/bundle/\(name)"]))
#expect(coordinator.sftpTransfers.allSatisfy { $0.direction == .copy && $0.localPath == readme.path })
let existing = try await targetClient.attributes(at: "/incoming/bundle/README.md")
#expect(existing?.kind == (policy == .replace && kind == .directory ? nil : kind))
}
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth])
func remoteCopyDirectoryConflictsApplyToTheWholeSubtree(policy: TransferConflictPolicy) async throws {
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
let nested = remoteFile(path: "/source/bundle/nested", kind: .directory)
let file = remoteFile(path: "/source/bundle/nested/payload.txt", kind: .file)
let sibling = remoteFile(path: "/source/bundle-other", kind: .directory)
let source = TreeSFTPClient(listings: [bundle.path: [nested], nested.path: [file]])
let destination = ManifestSFTPClient(
entries: ["/incoming/bundle": remoteFile(path: "/incoming/bundle", kind: .file)],
rejectedAttributePrefix: policy == .replace ? nil : "/incoming/bundle/"
)
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { source }
let payload = coordinator.registerRemoteCopySource(
SFTPRemoteCopySource(hostID: UUID(), configuration: testConfiguration, files: [bundle, sibling]))
let session = SFTPSessionModel(
hostID: UUID(), initialPath: "/incoming", client: destination, configuration: testConfiguration
)
await session.prepareRemoteCopy(payload, coordinator: coordinator)
#expect(session.conflictCount == 1)
await session.enqueuePendingRemoteCopy(policy: policy, coordinator: coordinator)
#expect(session.operationError == nil)
#expect(!session.hasPendingBatch)
#expect(await destination.rejectedAttributeRequestCount == 0)
#expect(try await destination.attributes(at: "/incoming/bundle-other")?.isDirectory == true)
let directory = policy == .keepBoth ? "/incoming/bundle.2" : "/incoming/bundle"
#expect(
coordinator.sftpTransfers.map(\.remotePath) == (policy == .skip ? [] : [directory + "/nested/payload.txt"]))
if policy != .replace { #expect(try await destination.attributes(at: "/incoming/bundle")?.kind == .file) }
}
@Test
func remoteCopyOfEmptyDirectoriesRefreshesTheBrowserAndHonorsPendingCancellation() async throws {
let empty = remoteFile(path: "/source/empty", kind: .directory)
let source = TreeSFTPClient(listings: [:])
let destination = ManifestSFTPClient(entries: [:])
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { source }
let payload = coordinator.registerRemoteCopySource(
SFTPRemoteCopySource(hostID: UUID(), configuration: testConfiguration, files: [empty]))
let session = SFTPSessionModel(
hostID: UUID(), initialPath: "/incoming", client: destination, configuration: testConfiguration
)
await session.prepareRemoteCopy(payload, coordinator: coordinator)
session.cancelPendingBatch()
await session.enqueuePendingRemoteCopy(policy: .replace, coordinator: coordinator)
#expect(await destination.createdDirectories.isEmpty)
await session.prepareRemoteCopy(payload, coordinator: coordinator)
await session.enqueuePendingRemoteCopy(policy: .replace, coordinator: coordinator)
for _ in 0..<200 {
if !session.files.isEmpty { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(session.files.map(\.path) == ["/incoming/empty"])
#expect(session.operationError == nil)
#expect(!session.hasPendingBatch)
#expect(coordinator.sftpTransfers.isEmpty)
}
@Test
@@ -1058,7 +1128,7 @@ private func makeTestConfiguration(hostID: UUID, host: String) -> SSHConnectionC
)
}
private func remoteFile(
private nonisolated func remoteFile(
path: String, kind: RemoteFile.Kind, size: Int64 = 0, modifiedAt: Date? = nil
) -> RemoteFile {
RemoteFile(
@@ -1211,7 +1281,7 @@ private actor BorrowedSFTPTransferClient: SFTPClientProtocol {
}
private actor ManifestSFTPClient: SFTPClientProtocol {
private let entries: [String: RemoteFile]
private var entries: [String: RemoteFile]
private let rejectedAttributePrefix: String?
private let home: String
private let rejectsHome: Bool
@@ -1239,7 +1309,9 @@ private actor ManifestSFTPClient: SFTPClientProtocol {
if rejectsHome { throw SFTPError.operation("Home unavailable") }
return home
}
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
func listDirectory(at path: String) async throws -> [RemoteFile] {
entries.values.filter { ($0.path as NSString).deletingLastPathComponent == path }
}
func attributes(at path: String) async throws -> RemoteFile? {
if let rejectedAttributePrefix, path.hasPrefix(rejectedAttributePrefix) {
rejectedAttributeRequestCount += 1
@@ -1248,10 +1320,13 @@ private actor ManifestSFTPClient: SFTPClientProtocol {
return entries[path]
}
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws { createdDirectories.append(path) }
func createDirectory(at path: String) async throws {
createdDirectories.append(path)
entries[path] = remoteFile(path: path, kind: .directory)
}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws {}
func removeDirectory(at path: String) async throws {}
func removeFile(at path: String) async throws { entries[path] = nil }
func removeDirectory(at path: String) async throws { entries[path] = nil }
func uploadFile(
from localURL: URL, to remotePath: String, overwrite: Bool,
cancellation: TransferCancellationToken,
+1 -12
View File
@@ -84,23 +84,12 @@ struct SSHHandshakeDiagnosticsTests {
#expect(server.acceptedConnections == 1)
}
@Test func handshakeFailureDescriptionsDistinguishBannerConditions() {
@Test func handshakeDiagnosticsRetainThePeerEndpoint() {
let peer = "203.0.113.4:22"
let disconnected = SSHHandshakeFailure(
code: -13, systemError: nil, phase: .identification,
elapsedMilliseconds: 18, peer: peer
)
let invalid = SSHHandshakeFailure(
code: -2, systemError: nil, phase: .identification,
elapsedMilliseconds: 7, peer: peer
)
let timedOut = SSHHandshakeFailure(
code: -30, systemError: ETIMEDOUT, phase: .identification,
elapsedMilliseconds: 20_000, peer: peer
)
#expect(invalid.localizedDescription.contains("invalid SSH identification"))
#expect(timedOut.localizedDescription.contains("timed out"))
#expect(disconnected.diagnosticDescription.contains("peer \(peer)"))
}
-1
View File
@@ -533,7 +533,6 @@ struct SSHImportTests {
#expect(window.vaultSelection(for: .knownHosts) == [.knownHost(entry.id)])
#expect(window.vaultPages.knownHosts.query == "key")
#expect(window.findKind == .knownHosts)
#expect(VaultPage.knownHosts.symbol == "touchid")
#expect(VaultPage.allCases.last == .knownHosts)
}
}
-1
View File
@@ -160,7 +160,6 @@ struct WindowToolbarFindTests {
#expect(actions.last?.startsGroup == true)
let menu = window.creationMenu(for: page, locale: Locale(identifier: "en"))
#expect(menu[menu.count - 2].isSeparator)
#expect(menu.last?.title == "Import .ssh Folder")
}
}