From a5dd042a85eb2b8ce1b86c40ffcf150c15231b83 Mon Sep 17 00:00:00 2001 From: zhukang <274546966@qq.com> Date: Fri, 13 Mar 2026 01:06:50 +0800 Subject: [PATCH] test/docs: freeze recovery credibility contract --- .../Execution/Recovery-Contract-2026-03-13.md | 135 ++++++++++++ ...very-Credibility-Gate-Review-2026-03-13.md | 93 ++++++++ Docs/README.md | 2 + Docs/plans/2026-03-13-recovery-credibility.md | 172 +++++++++++++++ .../AtlasInfrastructureTests.swift | 205 ++++++++++++++++++ 5 files changed, 607 insertions(+) create mode 100644 Docs/Execution/Recovery-Contract-2026-03-13.md create mode 100644 Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md create mode 100644 Docs/plans/2026-03-13-recovery-credibility.md diff --git a/Docs/Execution/Recovery-Contract-2026-03-13.md b/Docs/Execution/Recovery-Contract-2026-03-13.md new file mode 100644 index 0000000..167d452 --- /dev/null +++ b/Docs/Execution/Recovery-Contract-2026-03-13.md @@ -0,0 +1,135 @@ +# Recovery Contract — 2026-03-13 + +## Goal + +Freeze Atlas recovery semantics against the behavior that is actually shipped today. + +## Scope + +- `ATL-221` physical restore for file-backed recoverable actions where safe +- `ATL-222` restore validation on real file-backed test cases +- `ATL-223` README, in-app, and release-facing recovery claim audit +- `ATL-224` recovery contract and acceptance evidence freeze + +## Canonical Contract + +### 1. What a recovery item means + +- Every recoverable destructive flow must produce a structured `RecoveryItem`. +- A `RecoveryItem` may carry `restoreMappings` that pair the original path with the actual path returned from Trash. +- `restoreMappings` are the only shipped proof that Atlas can claim an on-disk return path for that item. + +### 2. When Atlas can claim physical restore + +Atlas can claim physical on-disk restore only when all of the following are true: + +- the recovery item still exists in Atlas history +- its retention window is still open +- the recovery item contains at least one `restoreMappings` entry +- the trashed source still exists on disk +- the original destination path does not already exist +- the required execution capability is available: + - direct move for supported user-trashable targets such as `~/Library/Caches/*` and `~/Library/pnpm/store/*` + - helper-backed restore for protected targets such as app bundles under `/Applications` or `~/Applications` + +### 3. When Atlas restores state only + +If a recovery item has no `restoreMappings`, Atlas may still restore Atlas workspace state by rehydrating the saved `Finding` or `AppFootprint` payload. + +State-only restore means: + +- the item reappears in Atlas UI state +- the action remains auditable in History +- Atlas does not claim the underlying file or bundle returned on disk + +### 4. Failure behavior + +Restore remains fail-closed. Atlas rejects the restore request instead of claiming success when: + +- the trash source no longer exists +- the original destination already exists +- the target falls outside the supported direct/helper allowlist +- a required helper capability is unavailable + +### 5. History and completion wording + +- Disk-backed restores must use the disk-specific completion summary. +- State-only restores must use the Atlas-only completion summary. +- Mixed restore batches must report both clauses instead of collapsing everything into a physical-restore claim. + +## Accepted Physical Restore Surface + +### Direct restore paths + +The currently proven direct restore surface is the same safe structured subset used by Smart Clean execution: + +- `~/Library/Caches/*` +- `~/Library/pnpm/store/*` +- other targets explicitly allowed by `AtlasSmartCleanExecutionSupport.isDirectlyTrashable` + +### Helper-backed restore paths + +The currently proven helper-backed restore surface includes app bundles that require the privileged helper path: + +- `/Applications/*.app` +- `~/Applications/*.app` + +## Claim Audit + +The following surfaces now match the frozen contract and must stay aligned: + +- `README.md` +- `README.zh-CN.md` +- `Docs/Protocol.md` +- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings` + +No additional README or in-app narrowing is required in this slice because the shipped wording already distinguishes: + +- supported on-disk restore +- Atlas-only state restoration +- fail-closed behavior for unsupported or unprovable actions + +## Release-Note-Safe Wording + +Future release notes must stay within these statements unless the restore surface expands and new evidence is added: + +- `Recoverable items can be restored when a supported recovery path is available.` +- `Some recoverable items restore on disk, while older or unstructured records restore Atlas state only.` +- `Atlas only claims physical return when it recorded a supported restore path for that item.` +- `Unsupported or unavailable restore paths fail closed instead of being reported as restored.` + +Avoid saying: + +- `All recoverable items restore physically.` +- `History always returns deleted files to disk.` +- `Restore succeeded` when Atlas only rehydrated workspace state. + +## Acceptance Evidence + +### Automated proof points + +- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` + - `testRestoreRecoveryItemPhysicallyRestoresRealTargets` + - `testExecuteAppUninstallRestorePhysicallyRestoresAppBundle` + - `testRestoreItemsStateOnlySummaryDoesNotClaimOnDiskRestore` + - `testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses` + - `testScanExecuteRescanRemovesExecutedTargetFromRealResults` + - `testScanExecuteRescanRemovesExecutedPnpmStoreTargetFromRealResults` +- `Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md` +- `Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md` + +### What the evidence proves + +- direct-trash file-backed recovery physically returns a real file to its original path +- helper-backed app uninstall recovery physically returns a real app bundle to its original path +- Atlas-only recovery records do not overclaim on-disk restore +- mixed restore batches preserve truthful summaries when disk-backed and Atlas-only items are restored together +- supported file-backed targets still satisfy `scan -> execute -> rescan` credibility checks + +## Remaining Limits + +- Physical restore is intentionally partial, not universal. +- Older or unstructured recovery entries remain Atlas-state-only unless they carry `restoreMappings`. +- Broader restore scope must not ship without new allowlist review, automated coverage, and matching copy updates. diff --git a/Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md b/Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md new file mode 100644 index 0000000..20a5d17 --- /dev/null +++ b/Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md @@ -0,0 +1,93 @@ +# Recovery Credibility Gate Review + +## Gate + +- `Recovery Credibility` + +## Review Date + +- `2026-03-13` + +## Scope Reviewed + +- `ATL-221` implement physical restore for file-backed recoverable actions where safe +- `ATL-222` validate shipped restore behavior on real file-backed test cases +- `ATL-223` narrow README, in-app, and release-note recovery claims if needed +- `ATL-224` freeze recovery contract and acceptance evidence +- `ATL-225` recovery credibility gate review + +## Readiness Checklist + +- [x] Required P0 tasks complete +- [x] Docs updated +- [x] Risks reviewed +- [x] Open questions below threshold +- [x] Next-stage inputs available + +## Evidence Reviewed + +- `Docs/Protocol.md` +- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` +- `Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md` +- `Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md` +- `Docs/Execution/Recovery-Contract-2026-03-13.md` +- `README.md` +- `README.zh-CN.md` +- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings` + +## Automated Validation Summary + +- `swift test --package-path Packages --filter AtlasInfrastructureTests` — pass +- `swift test --package-path Packages` — pass + +## Gate Assessment + +### ATL-221 Physical Restore Surface + +- File-backed recovery items now restore physically when Atlas recorded `restoreMappings` from a real Trash move. +- Supported direct-trash targets restore back to their original on-disk path. +- Protected app-bundle targets restore through the helper-backed path instead of claiming an unproven direct move. +- Restore remains fail-closed when the source, destination, or capability contract is not satisfied. + +### ATL-222 Shipped Restore Evidence + +- Automated tests now cover both proven physical restore classes: + - direct-trash file-backed Smart Clean targets + - helper-backed app uninstall targets +- State-only recovery remains explicitly covered so Atlas does not regress into overclaiming physical restore. +- Mixed restore summaries are covered so a batch containing both kinds of items stays truthful. + +### ATL-223 Claim Audit + +- README and localized in-app strings already reflect the narrowed recovery promise. +- No new copy narrowing was required in this slice. +- This gate freezes a release-note-safe wording set in `Docs/Execution/Recovery-Contract-2026-03-13.md` so future release notes cannot overstate restore behavior. + +### ATL-224 Contract Freeze + +- The recovery contract is now explicit, evidence-backed, and tied to shipped protocol fields and worker behavior. +- The contract distinguishes physical restore from Atlas-only state rehydration and documents the exact failure conditions. + +## Remaining Limits + +- Physical restore is still partial and depends on supported `restoreMappings`. +- Older or unstructured recovery items still restore Atlas state only. +- Broader restore coverage, including additional protected or system-managed targets, must not be described as shipped until new allowlist and QA evidence exist. + +## Decision + +- `Pass with Conditions` + +## Conditions + +- Release-facing copy must continue to use the frozen wording in `Docs/Execution/Recovery-Contract-2026-03-13.md`. +- Any future restore-surface expansion must add automated proof for the new target class before copy is widened. +- Candidate-build QA should still rerun the manual restore checklist on packaged artifacts before external distribution. + +## Follow-up Actions + +- Reuse the frozen recovery contract in future release notes and internal beta notices. +- Add new restore targets only after allowlist review, helper-path review, and contract tests land together. +- Re-run packaged-app manual restore verification when signed distribution work resumes. diff --git a/Docs/README.md b/Docs/README.md index 73fd2bf..88c8ecf 100644 --- a/Docs/README.md +++ b/Docs/README.md @@ -30,6 +30,8 @@ This directory contains the working product, design, engineering, and compliance - `Execution/Execution-Chain-Audit-2026-03-09.md` — end-to-end review of real vs scaffold execution paths and release-facing trust gaps - `Execution/Implementation-Plan-ATL-201-202-205-2026-03-12.md` — implementation plan for internal-beta hardening tasks ATL-201, ATL-202, and ATL-205 - `Execution/Execution-Credibility-Gate-Review-2026-03-12.md` — gate review for ATL-211, ATL-212, and ATL-215 Smart Clean execution credibility work +- `Execution/Recovery-Contract-2026-03-13.md` — frozen recovery semantics, claim boundaries, and acceptance evidence for ATL-221 through ATL-224 +- `Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` — gate review for ATL-221 through ATL-225 recovery credibility work - `Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` — user-facing summary of what Smart Clean can execute for real today - `Execution/Smart-Clean-QA-Checklist-2026-03-09.md` — QA checklist for scan, execute, rescan, and physical restore validation - `Execution/Smart-Clean-Manual-Verification-2026-03-09.md` — local-machine fixture workflow for validating real Smart Clean execution and restore diff --git a/Docs/plans/2026-03-13-recovery-credibility.md b/Docs/plans/2026-03-13-recovery-credibility.md new file mode 100644 index 0000000..00671db --- /dev/null +++ b/Docs/plans/2026-03-13-recovery-credibility.md @@ -0,0 +1,172 @@ +# Recovery Credibility Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Freeze Atlas recovery semantics against shipped behavior by adding missing restore coverage and publishing explicit acceptance evidence and a gate review for ATL-221 through ATL-225. + +**Architecture:** The worker already supports restore mappings for file-backed recovery items and Atlas-only rehydration for older/state-only records. This slice should avoid widening restore scope; instead it should prove the current contract with focused automated tests, then freeze that contract in execution docs and a recovery gate review. + +**Tech Stack:** Swift Package Manager, XCTest, Markdown docs + +--- + +### Task 1: Add helper-backed app restore coverage + +**Files:** +- Modify: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- Check: `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift` + +**Step 1: Write the failing test** + +Add a test that: +- creates a fake installed app under `~/Applications/AtlasExecutionTests/...` +- injects a stub `AtlasPrivilegedActionExecuting` +- executes app uninstall +- restores the resulting recovery item +- asserts the app bundle returns to its original path and the restore summary uses the disk-backed wording + +**Step 2: Run test to verify it fails** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testExecuteAppUninstallRestorePhysicallyRestoresAppBundle` +Expected: FAIL until the stub/helper-backed path is wired correctly in the test. + +**Step 3: Write minimal implementation** + +Implement only the test support needed: +- a stub helper executor that handles `.trashItems` and `.restoreItem` +- deterministic assertions for returned `restoreMappings` + +**Step 4: Run test to verify it passes** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testExecuteAppUninstallRestorePhysicallyRestoresAppBundle` +Expected: PASS + +**Step 5: Commit** + +```bash +git add Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +git commit -m "test: cover helper-backed app restore" +``` + +### Task 2: Add mixed recovery summary coverage + +**Files:** +- Modify: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- Check: `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:1086` + +**Step 1: Write the failing test** + +Add a test that restores: +- one recovery item with `restoreMappings` +- one recovery item without `restoreMappings` + +Assert the task summary contains both: +- disk restore wording +- Atlas-only restore wording + +**Step 2: Run test to verify it fails** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses` +Expected: FAIL if the combined contract is not proven yet. + +**Step 3: Write minimal implementation** + +If needed, adjust only test fixtures or summary generation so mixed restores preserve both clauses without overstating physical restore. + +**Step 4: Run test to verify it passes** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses` +Expected: PASS + +**Step 5: Commit** + +```bash +git add Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +git commit -m "test: cover mixed recovery summaries" +``` + +### Task 3: Freeze recovery contract and evidence + +**Files:** +- Create: `Docs/Execution/Recovery-Contract-2026-03-13.md` +- Create: `Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` +- Modify: `Docs/README.md` +- Check: `Docs/Protocol.md` +- Check: `README.md` +- Check: `README.zh-CN.md` +- Check: `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings` +- Check: `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings` + +**Step 1: Write the contract doc** + +Document exactly what Atlas promises today: +- file-backed recovery physically restores only when `restoreMappings` exist +- Atlas-only recovery rehydrates workspace state without claiming on-disk return +- helper-backed restore is required for protected paths like app bundles +- restore fails closed when the trash source is gone, the destination already exists, or helper capability is unavailable + +**Step 2: Write the evidence section** + +Reference automated proof points: +- direct-trash cache restore test +- helper-backed app uninstall restore test +- mixed summary/state-only tests +- existing `scan -> execute -> rescan` coverage for supported targets + +**Step 3: Write the gate review** + +Mirror the existing execution gate format and record: +- scope reviewed (`ATL-221` to `ATL-225`) +- evidence reviewed +- automated validation summary +- remaining limits +- decision and follow-up conditions + +**Step 4: Update docs index** + +Add the new recovery contract and gate review docs to `Docs/README.md`. + +**Step 5: Commit** + +```bash +git add Docs/Execution/Recovery-Contract-2026-03-13.md Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md Docs/README.md +git commit -m "docs: freeze recovery contract and gate evidence" +``` + +### Task 4: Run focused validation + +**Files:** +- Check: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- Check: `Docs/Execution/Recovery-Contract-2026-03-13.md` +- Check: `Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` + +**Step 1: Run targeted infrastructure tests** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests` +Expected: PASS + +**Step 2: Run broader package tests** + +Run: `swift test --package-path Packages` +Expected: PASS + +**Step 3: Sanity-check docs claims** + +Verify every new doc line matches one of: +- protocol contract +- localized UI copy +- automated test evidence + +**Step 4: Summarize remaining limits** + +Call out that: +- physical restore is still partial by design +- unsupported or older recovery items remain Atlas-state-only +- broader restore scope should not expand without new allowlist and QA evidence + +**Step 5: Commit** + +```bash +git add Docs/README.md Docs/Execution/Recovery-Contract-2026-03-13.md Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +git commit -m "chore: validate recovery credibility slice" +``` diff --git a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift index 747764f..404dfa1 100644 --- a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +++ b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift @@ -709,6 +709,101 @@ final class AtlasInfrastructureTests: XCTestCase { ) } + func testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let home = FileManager.default.homeDirectoryForCurrentUser + let targetDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + let targetFile = targetDirectory.appendingPathComponent("sample.cache") + try Data("cache".utf8).write(to: targetFile) + + var trashedURL: NSURL? + try FileManager.default.trashItem(at: targetFile, resultingItemURL: &trashedURL) + let trashedPath = try XCTUnwrap((trashedURL as URL?)?.path) + + addTeardownBlock { + try? FileManager.default.removeItem(at: targetDirectory) + if let trashedURL { + try? FileManager.default.removeItem(at: trashedURL as URL) + } + } + + let fileBackedFinding = Finding( + id: UUID(), + title: "Disk-backed fixture", + detail: targetFile.path, + bytes: 5, + risk: .safe, + category: "Developer tools", + targetPaths: [targetFile.path] + ) + let stateOnlyFinding = Finding( + id: UUID(), + title: "Atlas-only fixture", + detail: "State-only recovery item", + bytes: 7, + risk: .safe, + category: "Developer tools" + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [], + taskRuns: [], + recoveryItems: [ + RecoveryItem( + id: UUID(), + title: fileBackedFinding.title, + detail: fileBackedFinding.detail, + originalPath: targetFile.path, + bytes: fileBackedFinding.bytes, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .finding(fileBackedFinding), + restoreMappings: [RecoveryPathMapping(originalPath: targetFile.path, trashedPath: trashedPath)] + ), + RecoveryItem( + id: UUID(), + title: stateOnlyFinding.title, + detail: stateOnlyFinding.detail, + originalPath: "~/Library/Caches/AtlasOnly", + bytes: stateOnlyFinding.bytes, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .finding(stateOnlyFinding), + restoreMappings: nil + ), + ], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let restoreItemIDs = state.snapshot.recoveryItems.map(\.id) + let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: false) + let restore = try await worker.submit(AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: restoreItemIDs))) + + if case let .accepted(task) = restore.response.response { + XCTAssertEqual(task.kind, .restore) + } else { + XCTFail("Expected accepted restore response") + } + XCTAssertTrue(FileManager.default.fileExists(atPath: targetFile.path)) + XCTAssertTrue(restore.snapshot.findings.contains(where: { $0.id == fileBackedFinding.id })) + XCTAssertTrue(restore.snapshot.findings.contains(where: { $0.id == stateOnlyFinding.id })) + XCTAssertEqual( + restore.snapshot.taskRuns.first?.summary, + [ + AtlasL10n.string("infrastructure.restore.summary.disk.one", language: state.settings.language), + AtlasL10n.string("infrastructure.restore.summary.state.one", language: state.settings.language), + ].joined(separator: " ") + ) + } + func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws { let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true) @@ -724,6 +819,84 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertEqual(result.snapshot.taskRuns.first?.kind, .uninstallApp) } + func testExecuteAppUninstallRestorePhysicallyRestoresAppBundle() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let fileManager = FileManager.default + let appRoot = fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + let appBundleURL = appRoot.appendingPathComponent("Atlas Restore Test.app", isDirectory: true) + try fileManager.createDirectory(at: appBundleURL, withIntermediateDirectories: true) + let executableURL = appBundleURL.appendingPathComponent("Contents/MacOS/AtlasRestoreTest") + try fileManager.createDirectory(at: executableURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executableURL) + + addTeardownBlock { + try? FileManager.default.removeItem(at: appRoot) + } + + let app = AppFootprint( + id: UUID(), + name: "Atlas Restore Test", + bundleIdentifier: "com.atlas.restore-test", + bundlePath: appBundleURL.path, + bytes: 17, + leftoverItems: 1 + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: app.bytes, + findings: [], + apps: [app], + taskRuns: [], + recoveryItems: [], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let worker = AtlasScaffoldWorkerService( + repository: repository, + helperExecutor: StubPrivilegedHelperExecutor(), + allowStateOnlyCleanExecution: false + ) + + let execute = try await worker.submit( + AtlasRequestEnvelope(command: .executeAppUninstall(appID: app.id)) + ) + + if case let .accepted(task) = execute.response.response { + XCTAssertEqual(task.kind, .uninstallApp) + } else { + XCTFail("Expected accepted uninstall response") + } + + XCTAssertFalse(fileManager.fileExists(atPath: appBundleURL.path)) + XCTAssertFalse(execute.snapshot.apps.contains(where: { $0.id == app.id })) + + let recoveryItem = try XCTUnwrap(execute.snapshot.recoveryItems.first) + XCTAssertEqual(recoveryItem.restoreMappings?.first?.originalPath, appBundleURL.path) + XCTAssertNotNil(recoveryItem.restoreMappings?.first?.trashedPath) + + let restore = try await worker.submit( + AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id])) + ) + + if case let .accepted(task) = restore.response.response { + XCTAssertEqual(task.kind, .restore) + } else { + XCTFail("Expected accepted restore response") + } + + XCTAssertTrue(fileManager.fileExists(atPath: appBundleURL.path)) + XCTAssertTrue(restore.snapshot.apps.contains(where: { $0.id == app.id })) + XCTAssertEqual( + restore.snapshot.taskRuns.first?.summary, + AtlasL10n.string("infrastructure.restore.summary.disk.one", language: state.settings.language) + ) + } + private func temporaryStateFileURL() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -761,3 +934,35 @@ private struct FileBackedSmartCleanProvider: AtlasSmartCleanScanProviding { return AtlasSmartCleanScanResult(findings: [finding], summary: "Found 1 reclaimable item.") } } + +private actor StubPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting { + func perform(_ action: AtlasHelperAction) async throws -> AtlasHelperActionResult { + let fileManager = FileManager.default + let targetURL = URL(fileURLWithPath: action.targetPath) + + switch action.kind { + case .trashItems: + var trashedURL: NSURL? + try fileManager.trashItem(at: targetURL, resultingItemURL: &trashedURL) + return AtlasHelperActionResult( + action: action, + success: true, + message: "Moved item to Trash.", + resolvedPath: (trashedURL as URL?)?.path + ) + case .restoreItem: + let destinationPath = try XCTUnwrap(action.destinationPath) + let destinationURL = URL(fileURLWithPath: destinationPath) + try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.moveItem(at: targetURL, to: destinationURL) + return AtlasHelperActionResult( + action: action, + success: true, + message: "Restored item from Trash.", + resolvedPath: destinationURL.path + ) + case .removeLaunchService, .repairOwnership: + throw NSError(domain: "StubPrivilegedHelperExecutor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unsupported test helper action: \(action.kind)"]) + } + } +}