Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions docs/e2e-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
270 changes: 270 additions & 0 deletions scripts/e2e-verify.ps1
Original file line number Diff line number Diff line change
@@ -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-<guid>.
.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 })
3 changes: 3 additions & 0 deletions src/ClaudePortable.App/ClaudePortable.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>

Expand Down
24 changes: 24 additions & 0 deletions src/ClaudePortable.App/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
}
16 changes: 16 additions & 0 deletions src/ClaudePortable.App/Ui/Converters/StringToVisibleConverter.cs
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 3 additions & 0 deletions src/ClaudePortable.App/Ui/Services/UiLogSink.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Globalization;
using Serilog;

namespace ClaudePortable.App.Ui.Services;

Expand All @@ -18,5 +19,7 @@ public void Append(string line)
{
Entries.RemoveAt(0);
}

Log.Information("{Message}", line);
}
}
Loading
Loading