diff --git a/docs/e2e-test.md b/docs/e2e-test.md index 003c22b..7a0a948 100644 --- a/docs/e2e-test.md +++ b/docs/e2e-test.md @@ -67,10 +67,16 @@ Run this before tagging a release that changes anything in `ClaudePortable.Core` ## Automating the verification (optional) -A PowerShell verification script template lives in `scripts/e2e-verify.ps1` (TODO, not yet written). It should: -1. Check `Test-Path` for each path in `WindowsPathDiscovery`. -2. Read the restored `claude_desktop_config.json`, assert `mcpServers` keys match the pre-backup capture. -3. Read `extensions-installations.json`, diff extensions list against the backup manifest `sourcePaths`. -4. Assert `config.json` was NOT restored (file size should be 0 or absent). - -This script is a follow-up; see the "Automate E2E verification" issue on the repo. +A PowerShell verification script lives in `scripts/e2e-verify.ps1`. It: +1. Extracts the backup ZIP to a temp directory. +2. Validates `manifest.json` schema, required fields, and SHA-256 integrity. +3. Checks that expected backup content directories exist with file counts. +4. Asserts credential-bearing files (`tokens.dat`, `Login Data*`, `Cookies*`, `config.json`) are correctly excluded. +5. Verifies MCP server keys in `claude_desktop_config.json` against an optional expected list. +6. Checks that a post-restore checklist markdown was generated with required sections. + +Usage: +```powershell +.\scripts\e2e-verify.ps1 -BackupZip ".\claude-backup_20260529.zip" +.\scripts\e2e-verify.ps1 -BackupZip ".\backup.zip" -ExpectedMcpServers @("gmail", "slack") +``` diff --git a/scripts/e2e-verify.ps1 b/scripts/e2e-verify.ps1 new file mode 100644 index 0000000..36c7cea --- /dev/null +++ b/scripts/e2e-verify.ps1 @@ -0,0 +1,270 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Automated E2E verification for ClaudePortable backup/restore roundtrip. +.DESCRIPTION + Validates that a restored backup ZIP contains expected files, correct + exclusions, and consistent manifest data. Designed to run on VM-B + after a restore from VM-A in the OneDrive roundtrip test playbook. + + Usage: + .\e2e-verify.ps1 -BackupZip "C:\OneDrive\ClaudePortable\claude-backup_20260529.zip" + .\e2e-verify.ps1 -BackupZip ".\backup.zip" -RestoreDir "C:\Temp\restore-check" +.PARAMETER BackupZip + Path to the backup ZIP file to verify. +.PARAMETER RestoreDir + Directory where the ZIP will be extracted for inspection. Defaults to + a temp folder under $env:TEMP\ClaudePortable\e2e-verify-. +.PARAMETER ExpectedMcpServers + Optional JSON array of expected MCP server keys (from pre-backup capture). + Used to assert that mcpServers in claude_desktop_config.json match. +.EXAMPLE + .\e2e-verify.ps1 -BackupZip ".\claude-backup_20260529.zip" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BackupZip, + + [string]$RestoreDir, + + [string[]]$ExpectedMcpServers +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --- Helpers --- +$passed = 0 +$failed = 0 +$warnings = 0 + +function Write-Result { + param( + [string]$Name, + [bool]$Ok, + [string]$Detail = '', + [switch]$Warning + ) + $icon = if ($Ok) { '[PASS]' } elseif ($Warning) { '[WARN]' } else { '[FAIL]' } + $color = if ($Ok) { 'Green' } elseif ($Warning) { 'Yellow' } else { 'Red' } + Write-Host "$icon $Name" -ForegroundColor $color + if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGray } + if ($Ok) { $script:passed++ } + elseif ($Warning) { $script:warnings++ } + else { $script:failed++ } +} + +function Test-PathExists { + param([string]$Path, [string]$Label) + if (Test-Path $Path -PathType Leaf) { return $true } + Write-Host " Expected file not found: $Path" -ForegroundColor DarkRed + return $false +} + +function Test-DirExists { + param([string]$Path, [string]$Label) + if (Test-Path $Path -PathType Container) { return $true } + Write-Host " Expected directory not found: $Path" -ForegroundColor DarkRed + return $false +} + +# --- Pre-flight --- +Write-Host '' +Write-Host '========================================' -ForegroundColor Cyan +Write-Host ' ClaudePortable E2E Verification' -ForegroundColor Cyan +Write-Host '========================================' -ForegroundColor Cyan +Write-Host '' + +if (-not (Test-Path $BackupZip)) { + Write-Error "Backup ZIP not found: $BackupZip" + exit 1 +} + +$zipSha = (Get-FileHash $BackupZip -Algorithm SHA256).Hash.ToLower() +Write-Host "[INFO] ZIP SHA-256: $zipSha" -ForegroundColor DarkCyan + +if (-not $RestoreDir) { + $RestoreDir = Join-Path $env:TEMP "ClaudePortable\e2e-verify-$((New-Guid).ToString('N'))" +} +New-Item -ItemType Directory -Force -Path $RestoreDir | Out-Null + +# --- 1. Extract ZIP --- +Write-Host '[1/6] Extracting backup ZIP...' -ForegroundColor Yellow +try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($BackupZip, $RestoreDir) + Write-Result 'ZIP extraction' $true +} catch { + Write-Result 'ZIP extraction' $false "Could not extract: $_" + exit 2 +} + +# --- 2. Validate manifest.json --- +Write-Host '[2/6] Validating manifest...' -ForegroundColor Yellow +$manifestPath = Join-Path $RestoreDir 'manifest.json' +if (-not (Test-PathExists $manifestPath 'manifest.json')) { + Write-Result 'Manifest validation' $false 'manifest.json not found in ZIP root' +} else { + try { + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json + Write-Result 'Manifest JSON parse' $true + + # Schema version + if ($manifest.schemaVersion -ge 1) { + Write-Result 'Schema version' $true "v$($manifest.schemaVersion)" + } else { + Write-Result 'Schema version' $false "Unexpected schemaVersion: $($manifest.schemaVersion)" + } + + # Required fields + $requiredFields = @('createdAt', 'hostname', 'windowsUser', 'retentionTier', 'sourcePaths', 'archiveTargets', 'sizeBytes', 'fileCount', 'sha256') + foreach ($field in $requiredFields) { + if ($null -ne $manifest.$field) { + Write-Result "Manifest field: $field" $true + } else { + Write-Result "Manifest field: $field" $false 'Field is null or missing' + } + } + + # Manifest sha256 is a CONTENT hash (sorted relativePath + file bytes; + # see ZipArchiveWriter.ComputeContentHashAsync), NOT the hash of the + # .zip container - a zip cannot embed its own file hash. So assert the + # field is a well-formed digest, not that it equals the zip-file hash. + if ($manifest.sha256 -and $manifest.sha256 -match '^[0-9a-fA-F]{64}$') { + Write-Result 'Manifest content hash present' $true "sha256=$($manifest.sha256)" + } else { + Write-Result 'Manifest content hash present' $false 'sha256 missing or not a 64-char hex digest' + } + + # sourcePaths / archiveTargets serialize as JSON objects (C# Dictionary), + # so count their properties - the intrinsic .Count is always 1 on a + # PSCustomObject regardless of key count. + $sourceCount = @($manifest.sourcePaths.PSObject.Properties).Count + if ($sourceCount -gt 0) { + Write-Result 'Source paths populated' $true "$sourceCount entries" + } else { + Write-Result 'Source paths populated' $false 'No source paths in manifest' + } + + $targetCount = @($manifest.archiveTargets.PSObject.Properties).Count + if ($targetCount -gt 0) { + Write-Result 'Archive targets populated' $true "$targetCount entries" + } else { + Write-Result 'Archive targets populated' $false 'No archive targets in manifest' + } + + } catch { + Write-Result 'Manifest validation' $false "Parse error: $_" + } +} + +# --- 3. Check expected backup content --- +Write-Host '[3/6] Checking backup content...' -ForegroundColor Yellow +$expectedDirs = @( + @{ Path = 'claude-desktop/appdata'; Label = 'Claude Desktop appdata' }, + @{ Path = 'claude-code/dotclaude'; Label = 'Claude Code .claude' } +) + +foreach ($item in $expectedDirs) { + $zipPath = Join-Path $RestoreDir "$($item.Path)" + if (Test-DirExists $zipPath $item.Label) { + $fileCount = (Get-ChildItem $zipPath -Recurse -File).Count + Write-Result "Content: $($item.Label)" $true "$fileCount files" + } else { + Write-Result "Content: $($item.Label)" $false 'Directory not found in backup' + } +} + +# --- 4. Check credential exclusions --- +# DefaultExclusions.Globs removes these. Match by file NAME across the tree: +# Get-ChildItem -Filter does not understand '**/' or path separators, so the +# original globbed patterns silently matched nothing and always reported PASS. +Write-Host '[4/6] Checking credential exclusions...' -ForegroundColor Yellow +$allFiles = Get-ChildItem -Path $RestoreDir -Recurse -File -ErrorAction SilentlyContinue + +$nameExclusions = @( + @{ Match = { $_.Name -eq 'tokens.dat' }; Label = 'OAuth tokens (tokens.dat)' }, + @{ Match = { $_.Name -like 'Login Data*' }; Label = 'Browser login data' }, + @{ Match = { $_.Name -like 'Cookies*' }; Label = 'Browser cookies' } +) +foreach ($item in $nameExclusions) { + $hits = @($allFiles | Where-Object $item.Match) + if ($hits.Count -eq 0) { + Write-Result "Exclusion: $($item.Label)" $true 'Not found (correctly excluded)' + } else { + Write-Result "Exclusion: $($item.Label)" $false "Found $($hits.Count) file(s): $($hits.FullName -join ', ')" + } +} + +# config.json is excluded ONLY at the specific path claude-desktop/appdata/config.json +# (DefaultExclusions.Globs). Other config.json files are legitimately backed up, +# so assert that exact path is absent rather than every config.json. +$claudeConfig = Join-Path $RestoreDir 'claude-desktop/appdata/config.json' +if (-not (Test-Path $claudeConfig -PathType Leaf)) { + Write-Result 'Exclusion: Claude config (claude-desktop/appdata/config.json)' $true 'Not found (correctly excluded)' +} else { + Write-Result 'Exclusion: Claude config (claude-desktop/appdata/config.json)' $false "Present: $claudeConfig" +} + +# --- 5. MCP server verification --- +Write-Host '[5/6] Checking MCP servers...' -ForegroundColor Yellow +$configPath = Join-Path $RestoreDir 'claude-desktop/appdata/claude_desktop_config.json' +if (Test-PathExists $configPath 'claude_desktop_config.json') { + try { + $config = Get-Content $configPath -Raw | ConvertFrom-Json + if ($null -ne $config.mcpServers) { + $mcpKeys = @($config.mcpServers.PSObject.Properties.Name) + Write-Result 'MCP servers in config' $true "$($mcpKeys.Count) server(s): $($mcpKeys -join ', ')" + + if ($ExpectedMcpServers -and $ExpectedMcpServers.Count -gt 0) { + $missing = $ExpectedMcpServers | Where-Object { $_ -notin $mcpKeys } + if ($missing.Count -eq 0) { + Write-Result 'MCP servers match expected' $true 'All expected servers present' + } else { + Write-Result 'MCP servers match expected' $false "Missing: $($missing -join ', ')" + } + } + } else { + Write-Result 'MCP servers in config' $false 'mcpServers key not found' -Warning + } + } catch { + Write-Result 'MCP servers parse' $false "Parse error: $_" -Warning + } +} else { + Write-Result 'claude_desktop_config.json' $false 'Not found in backup' -Warning +} + +# --- 6. Post-restore checklist --- +Write-Host '[6/6] Checking post-restore checklist...' -ForegroundColor Yellow +$checklistPattern = 'post-restore-checklist-*.md' +$checklistFiles = Get-ChildItem -Path $env:LOCALAPPDATA\ClaudePortable -Filter $checklistPattern -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending + +if ($checklistFiles.Count -gt 0) { + $latestChecklist = $checklistFiles[0] + Write-Result 'Post-restore checklist exists' $true $latestChecklist.Name + + $content = Get-Content $latestChecklist.FullName -Raw + $requiredSections = @('Required Steps', 'Safety Backups', 'Troubleshooting') + foreach ($section in $requiredSections) { + if ($content -match [regex]::Escape($section)) { + Write-Result "Checklist section: $section" $true + } else { + Write-Result "Checklist section: $section" $false 'Section not found' + } + } +} else { + Write-Result 'Post-restore checklist exists' $false 'No checklist file found in %LOCALAPPDATA%\ClaudePortable\' -Warning +} + +# --- Summary --- +Write-Host '' +Write-Host '========================================' -ForegroundColor Cyan +Write-Host " Results: $passed passed, $failed failed, $warnings warnings" -ForegroundColor $(if ($failed -eq 0) { 'Green' } else { 'Red' }) +Write-Host '========================================' -ForegroundColor Cyan +Write-Host '' + +# Cleanup +try { Remove-Item $RestoreDir -Recurse -Force -ErrorAction SilentlyContinue } catch { } + +exit $(if ($failed -gt 0) { 1 } else { 0 }) diff --git a/src/ClaudePortable.App/ClaudePortable.App.csproj b/src/ClaudePortable.App/ClaudePortable.App.csproj index 3ca7892..8e2f7c8 100644 --- a/src/ClaudePortable.App/ClaudePortable.App.csproj +++ b/src/ClaudePortable.App/ClaudePortable.App.csproj @@ -7,6 +7,9 @@ + + + diff --git a/src/ClaudePortable.App/Program.cs b/src/ClaudePortable.App/Program.cs index 82f686c..9a14385 100644 --- a/src/ClaudePortable.App/Program.cs +++ b/src/ClaudePortable.App/Program.cs @@ -1,6 +1,8 @@ using System.CommandLine; +using System.Globalization; using System.Runtime.InteropServices; using ClaudePortable.App.Commands; +using Serilog; using UiApp = ClaudePortable.App.Ui.App; namespace ClaudePortable.App; @@ -12,6 +14,8 @@ public static class Program [STAThread] public static int Main(string[] args) { + ConfigureLogging(); + if (args.Length == 0 || args.Contains("--gui")) { return UiApp.RunGui(); @@ -60,4 +64,24 @@ private static void EnsureConsoleForCli() [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool AttachConsole(int processId); + + private static void ConfigureLogging() + { + var logDir = Path.Combine( + Environment.ExpandEnvironmentVariables("%LOCALAPPDATA%"), + "ClaudePortable", + "logs"); + Directory.CreateDirectory(logDir); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File( + Path.Combine(logDir, "claudeportable-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}", + formatProvider: CultureInfo.InvariantCulture) + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .CreateLogger(); + } } diff --git a/src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs b/src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs new file mode 100644 index 0000000..0329d68 --- /dev/null +++ b/src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs @@ -0,0 +1,16 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ClaudePortable.App.Ui.Converters; + +public sealed class StringToVisibleConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return !string.IsNullOrEmpty(value as string) ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudePortable.App/Ui/Services/UiLogSink.cs b/src/ClaudePortable.App/Ui/Services/UiLogSink.cs index e3fd670..3339a7c 100644 --- a/src/ClaudePortable.App/Ui/Services/UiLogSink.cs +++ b/src/ClaudePortable.App/Ui/Services/UiLogSink.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Globalization; +using Serilog; namespace ClaudePortable.App.Ui.Services; @@ -18,5 +19,7 @@ public void Append(string line) { Entries.RemoveAt(0); } + + Log.Information("{Message}", line); } } diff --git a/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs b/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs index 6e153e6..3d299e7 100644 --- a/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs +++ b/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs @@ -115,10 +115,19 @@ public bool ProgressIsIndeterminate public AsyncRelayCommand RestoreCommand { get; } public AsyncRelayCommand RestoreFromFileCommand { get; } public RelayCommand PickTargetProfileCommand { get; } + public RelayCommand OpenChecklistCommand { get; } public TargetEntry? SelectedTarget { get; set; } public BackupEntry? SelectedBackup { get; set; } + private string _postRestoreChecklistPath = string.Empty; + + public string PostRestoreChecklistPath + { + get => _postRestoreChecklistPath; + private set => SetField(ref _postRestoreChecklistPath, value); + } + public MainViewModel() { foreach (var path in _store.Load()) @@ -149,6 +158,7 @@ public MainViewModel() RestoreCommand = new AsyncRelayCommand(RestoreAsync, () => SelectedBackup is not null); RestoreFromFileCommand = new AsyncRelayCommand(RestoreFromFileAsync); PickTargetProfileCommand = new RelayCommand(PickTargetProfile); + OpenChecklistCommand = new RelayCommand(OpenChecklist, () => !string.IsNullOrEmpty(PostRestoreChecklistPath)); _ = RefreshAsync(); } @@ -376,6 +386,8 @@ private async Task RunRestoreAsync(string zipPath, string displayName) var totalWarnings = outcome.PerTargetReports.Sum(r => r.Warnings.Count); UiLogSink.Instance.Append($"restore complete. safety backups: {outcome.SafetyBackups.Count}, warnings: {totalWarnings}"); UiLogSink.Instance.Append($"version gate: {outcome.VersionGate.Level} - {outcome.VersionGate.Message}"); + + PostRestoreChecklistPath = outcome.PostRestoreChecklistPath; Status = totalWarnings == 0 ? $"Restore complete. Checklist: {outcome.PostRestoreChecklistPath}" : $"Restore complete with {totalWarnings} warning(s). See Logs tab."; @@ -467,6 +479,29 @@ private static async Task EnsureClaudeDesktopClosedAsync() return System.Diagnostics.Process.GetProcessesByName("Claude").Length == 0; } + private void OpenChecklist() + { + if (string.IsNullOrEmpty(PostRestoreChecklistPath) || !File.Exists(PostRestoreChecklistPath)) + { + Status = "No checklist available yet. Run a restore first."; + return; + } + + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = PostRestoreChecklistPath, + UseShellExecute = true, + }); + Status = $"Opened checklist: {PostRestoreChecklistPath}"; + } + catch (Exception ex) + { + Status = $"Could not open checklist: {ex.Message}"; + } + } + /// /// For every detected sync client (OneDrive, Dropbox, GDrive Desktop, ...) /// check whether the user already has a folder called "ClaudePortable" diff --git a/src/ClaudePortable.App/Ui/Views/MainWindow.xaml b/src/ClaudePortable.App/Ui/Views/MainWindow.xaml index db6eb36..63060c6 100644 --- a/src/ClaudePortable.App/Ui/Views/MainWindow.xaml +++ b/src/ClaudePortable.App/Ui/Views/MainWindow.xaml @@ -14,6 +14,7 @@ TextOptions.TextRenderingMode="Grayscale"> + @@ -295,6 +296,10 @@ Margin="0,0,8,0" />