diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e9a90e6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test repocopy + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + bash-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: sudo apt update && sudo apt install -y jq xclip bats + - name: Run Bash tests + run: bats tests/bash/rpcp.bats + + pester-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install PowerShell & Pester + uses: actions/setup-python@v4 # just to get pwsh; GitHub image already has pwsh + - name: Install Pester + shell: pwsh + run: | + Install-Module Pester -Force -Scope CurrentUser + - name: Run PowerShell tests + shell: pwsh + run: | + Invoke-Pester -Path tests/powershell -CI -Output Detailed + \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..367a773 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "tests/test_helper/bats-support"] + path = tests/test_helper/bats-support + url = https://github.com/bats-core/bats-support +[submodule "tests/test_helper/bats-assert"] + path = tests/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert diff --git a/README.md b/README.md index f1c2313..592b0ab 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,22 @@ After running `rpcp`, your clipboard contains all relevant files with context. P ## ๐Ÿงช Testing & Linting -- **Pester**: Write tests for PowerShell functions. -- **PSScriptAnalyzer**: Validate PowerShell style. -- **ShellCheck**: Lint the Bash script. +# Running tests locally + +## Bash (Bats) + +```bash +sudo apt install bats jq xclip # or the equivalent for your OS +bats tests/bash/repocopy.bats +``` + +## PowerShell (Pester) + +``` +Install-Module Pester -Force -Scope CurrentUser # once +Invoke-Pester -Path tests/powershell -Output Detailed +``` + --- diff --git a/config.json b/config.json index b73a18b..66d820b 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,9 @@ { "repoPath": ".", "maxFileSize": 204800, - "ignoreFolders": [".git", ".github", ".terraform", "node_modules","plugin-cache", "terraform-provider*", "logo-drafts","build", ".archive"], + "ignoreFolders": [".git", ".github", ".terraform", "node_modules","plugin-cache", "terraform-provider*", "logo-drafts","build", ".archive","test_helper"], "ignoreFiles": ["manifest.json", "package-lock.json","*.png","*.jpg","*.jpeg","*.gif","*.svg","*.zip","*.tar.gz","*.tgz","*.tfstate", "*.tfstate.backup", "*.tfvars", "*.tfvars.json", "*.tfplan", "*.tfplan.json","*.avif","*.webp"], + "replacements": { "PARENT_COMPANY":"pca","Bob":"Redacted_name","PROJECT_ACRONYM":"wla" }, "replacements": { "PARENT_COMPANY":"pca","CLIENT_NAME":"ClientName","PROJECT_ACRONYM":"wla" }, "showCopiedFiles": true, "autoInstallDeps": true diff --git a/rpcp.ps1 b/rpcp.ps1 index 78fb070..5385fbd 100644 --- a/rpcp.ps1 +++ b/rpcp.ps1 @@ -71,14 +71,16 @@ Param( [long] $MaxFileSize, [Parameter()] - [ValidateNotNullOrEmpty()] + [AllowNull()] [string[]] $IgnoreFolders, [Parameter()] - [ValidateNotNullOrEmpty()] + [AllowNull()] [string[]] $IgnoreFiles, [Parameter()] + [AllowNull()] + [hashtable]$Replacements, [Parameter()] @@ -103,17 +105,20 @@ function Get-Config { function Get-FilesToInclude { [CmdletBinding()] Param( - [Parameter(Mandatory)] + [Parameter()] [ValidateScript({ Test-Path $_ -PathType Container })] [string] $RepoRoot, - [Parameter(Mandatory)] - [string[]] $IgnoreFolders, + # โ†“โ†“โ†“ CHANGE #1 โ€“ remove Mandatory, give default @() + [Parameter()] + [string[]] $IgnoreFolders = @(), - [Parameter(Mandatory)] - [string[]] $IgnoreFiles, + # โ†“โ†“โ†“ CHANGE #2 โ€“ remove Mandatory, give default @() + [Parameter()] + [string[]] $IgnoreFiles = @(), + + [Parameter()] - [Parameter(Mandatory)] [ValidateRange(0, [long]::MaxValue)] [long] $MaxFileSize ) @@ -125,8 +130,15 @@ function Get-FilesToInclude { # Folder pattern check $dirs = $f.DirectoryName.Split([IO.Path]::DirectorySeparatorChar) foreach ($pat in $IgnoreFolders) { - if ($dirs -like $pat) { - $reason = "matched ignore-folder '$pat'"; break + $sepRegex = [Regex]::Escape([IO.Path]::DirectorySeparatorChar) + $segments = $f.DirectoryName -split $sepRegex # safe on Win & *nix + + foreach ($pat in $IgnoreFolders) { + if ($segments -like $pat) { + $reason = "matched ignore-folder '$pat'" + break + } + } } # File name check @@ -228,15 +240,27 @@ $config = Get-Config -ConfigFilePath $ConfigFile # Merge CLI parameters over config values $rp = if ($PSBoundParameters.ContainsKey('RepoPath')) { $RepoPath } else { $config.repoPath } $mf = if ($PSBoundParameters.ContainsKey('MaxFileSize')) { $MaxFileSize } else { [long]$config.maxFileSize } -$if = if ($PSBoundParameters.ContainsKey('IgnoreFolders')) { $IgnoreFolders } else { @($config.ignoreFolders) } -$ifl = if ($PSBoundParameters.ContainsKey('IgnoreFiles')) { $IgnoreFiles } else { @($config.ignoreFiles) } +$if = if ($PSBoundParameters.ContainsKey('IgnoreFolders') -and $IgnoreFolders) { $IgnoreFolders } else { @($config.ignoreFolders) } +$ifl = if ($PSBoundParameters.ContainsKey('IgnoreFiles') -and $IgnoreFiles) { $IgnoreFiles } else { @($config.ignoreFiles) } $rep = if ($PSBoundParameters.ContainsKey('Replacements')) { $Replacements } else { $h = @{}; foreach ($p in $config.replacements.PSObject.Properties) { $h[$p.Name] = $p.Value }; $h } -$scf = if ($PSBoundParameters.ContainsKey('ShowCopiedFiles')) { $ShowCopiedFiles.IsPresent } else { [bool]$config.showCopiedFiles } +$scf = if ($PSBoundParameters.ContainsKey('ShowCopiedFiles')) { + $ShowCopiedFiles.IsPresent + } else { + [bool]$config.showCopiedFiles + } + +if ($null -eq $if) { $if = @() } +if ($null -eq $ifl) { $ifl = @() } # Gather, filter, and log -$filesToCopy = Get-FilesToInclude -RepoRoot $rp -IgnoreFolders $if -IgnoreFiles $ifl -MaxFileSize $mf +$filesToCopy = Get-FilesToInclude ` + -RepoRoot $rp ` + -IgnoreFolders $if ` + -IgnoreFiles $ifl ` + -MaxFileSize $mf + if ($filesToCopy.Count -eq 0) { Write-Warning 'No files passed the filters; nothing to copy.' diff --git a/tests/bash/rpcp.bats b/tests/bash/rpcp.bats new file mode 100644 index 0000000..2421898 --- /dev/null +++ b/tests/bash/rpcp.bats @@ -0,0 +1,111 @@ +#!/usr/bin/env bats +# +# End-to-end tests for the Bash version of repocopy (rpcp.sh) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ€ข Spins-up a temp repo each run (safe & hermetic) +# โ€ข Stubs xclip/pbcopy so we can inspect what hits the clipboard +# โ€ข Verifies: +# 1. happy-path copy & token replacement +# 2. max-file-size exclusion +# 3. override of max-file-size via CLI +# 4. folder-ignore pattern ("build") +# 5. behaviour when --show-copied-files is used +# +export BATS_LIB_PATH="$PWD/tests/test_helper" +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' + +setup() { + # โ”€โ”€ โ‘  disposable sandbox repo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + TMP_REPO="$(mktemp -d)" + mkdir -p "$TMP_REPO/src" "$TMP_REPO/build" + + # source files + printf 'hello ClientName\n' >"$TMP_REPO/src/include.txt" + printf 'ignore me\n' >"$TMP_REPO/manifest.json" + head -c 10 "$TMP_REPO/image.png" + + # a 300-KiB file to test the size filter + dd if=/dev/zero of="$TMP_REPO/src/big.bin" bs=1k count=300 2>/dev/null + + # something inside an ignored folder + printf 'ignore me\n' >"$TMP_REPO/build/output.txt" + + # config.json that matches the Pester suite + cat >"$TMP_REPO/config.json" <<'JSON' +{ + "repoPath": ".", + "maxFileSize": 204800, + "ignoreFolders": [ "build" ], + "ignoreFiles": [ "manifest.json", "*.png", "config.json" ], + "replacements": { "ClientName": "Bob" }, + "showCopiedFiles": false, + "autoInstallDeps": false +} +JSON + + # โ”€โ”€ โ‘ก stub clipboard (xclip) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + CLIP_FILE="$(mktemp)" + STUB_DIR="$(mktemp -d)" + cat >"$STUB_DIR/xclip" < "$CLIP_FILE" +STUB + chmod +x "$STUB_DIR/xclip" + PATH="$STUB_DIR:$PATH" +} + +teardown() { + rm -rf "$TMP_REPO" "$CLIP_FILE" "$STUB_DIR" +} + +# helper to run rpcp.sh and slurp clipboard into $CLIP variable +run_rpcp() { + run bash ./rpcp.sh "$@" + # copy the clipboard text into a shell variable for assertions + CLIP="$(cat "$CLIP_FILE")" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +@test "default run: copies only permitted files & replaces tokens" { + run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" + + assert_success + assert_line --partial "โœ… Copied" # sanity + + assert_regex "$CLIP" "include\\.txt" + assert_regex "$CLIP" "hello Bob" + + refute_regex "$CLIP" "manifest\\.json" + refute_regex "$CLIP" "image\\.png" + refute_regex "$CLIP" "config\\.json" + refute_regex "$CLIP" "output\\.txt" + refute_regex "$CLIP" "big\\.bin" +} + +@test "size filter: big.bin is excluded by default" { + run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" + refute_regex "$CLIP" "big\\.bin" +} + +@test "size override: big.bin appears when --max-file-size 0 is used" { + run_rpcp --repo-path "$TMP_REPO" \ + --config-file "$TMP_REPO/config.json" \ + --max-file-size 0 + assert_regex "$CLIP" "big\\.bin" +} + +@test "folder ignore: anything under build/ is skipped" { + run_rpcp --repo-path "$TMP_REPO" --config-file "$TMP_REPO/config.json" + refute_regex "$CLIP" "build/output\\.txt" +} + +@test "--show-copied-files does not affect clipboard content" { + run_rpcp --repo-path "$TMP_REPO" \ + --config-file "$TMP_REPO/config.json" \ + --show-copied-files + + # The script prints the file list to stdout; + # we just need to ensure normal data is still on the clipboard + assert_regex "$CLIP" "include\\.txt" +} diff --git a/tests/fixtures/sample-repo/build/output.txt b/tests/fixtures/sample-repo/build/output.txt new file mode 100644 index 0000000..f709266 --- /dev/null +++ b/tests/fixtures/sample-repo/build/output.txt @@ -0,0 +1 @@ +ignore me diff --git a/tests/fixtures/sample-repo/config.json b/tests/fixtures/sample-repo/config.json new file mode 100644 index 0000000..c891671 --- /dev/null +++ b/tests/fixtures/sample-repo/config.json @@ -0,0 +1,8 @@ +{ + "repoPath": ".", + "maxFileSize": 204800, + "ignoreFolders": [ "build" ], + "ignoreFiles" : [ "manifest.json", "*.png", "config.json" ], + "replacements" : { "ClientName": "Bob" }, + "showCopiedFiles": false +} diff --git a/tests/fixtures/sample-repo/image.png b/tests/fixtures/sample-repo/image.png new file mode 100644 index 0000000..df93f5f Binary files /dev/null and b/tests/fixtures/sample-repo/image.png differ diff --git a/tests/fixtures/sample-repo/manifest.json b/tests/fixtures/sample-repo/manifest.json new file mode 100644 index 0000000..c1c52bb --- /dev/null +++ b/tests/fixtures/sample-repo/manifest.json @@ -0,0 +1 @@ +{ "note": "this file should be ignored by rpcp" } diff --git a/tests/fixtures/sample-repo/src/big.bin b/tests/fixtures/sample-repo/src/big.bin new file mode 100644 index 0000000..108b5ab Binary files /dev/null and b/tests/fixtures/sample-repo/src/big.bin differ diff --git a/tests/fixtures/sample-repo/src/include.txt b/tests/fixtures/sample-repo/src/include.txt new file mode 100644 index 0000000..cc9d96b --- /dev/null +++ b/tests/fixtures/sample-repo/src/include.txt @@ -0,0 +1 @@ +hello Bob diff --git a/tests/powershell/rpcp.tests.ps1 b/tests/powershell/rpcp.tests.ps1 new file mode 100644 index 0000000..8c876e3 --- /dev/null +++ b/tests/powershell/rpcp.tests.ps1 @@ -0,0 +1,146 @@ +# Pester 5 tests for repocopy (PowerShell edition) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Import-Module Pester -ErrorAction Stop +Import-Module Microsoft.PowerShell.Management -Force # Set-Clipboard + +$ErrorActionPreference = 'Stop' + +Describe 'rpcp.ps1 end-to-end behaviour (fixture repo)' { + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โถ Shared one-time setup + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + BeforeAll { + $ProjectRoot = (Resolve-Path "$PSScriptRoot/../../").Path + $Script = Join-Path $ProjectRoot 'rpcp.ps1' + $FixtureRoot = Join-Path $ProjectRoot 'tests/fixtures/sample-repo' + + # -- ensure fixture tree exists (idempotent) ------------------------ + if (-not (Test-Path $FixtureRoot)) { + New-Item -ItemType Directory -Path "$FixtureRoot/src" -Force | Out-Null + } + + # minimal source file & image (re-create only if missing) + if (-not (Test-Path "$FixtureRoot/src/include.txt")) { + 'hello ClientName' | Set-Content "$FixtureRoot/src/include.txt" + } + if (-not (Test-Path "$FixtureRoot/manifest.json")) { + '{ "note":"ignore" }' | Set-Content "$FixtureRoot/manifest.json" + } + if (-not (Test-Path "$FixtureRoot/image.png")) { + [IO.File]::WriteAllBytes("$FixtureRoot/image.png",[byte[]](0..9)) + } + @' +{ + "repoPath": ".", + "maxFileSize": 204800, + "ignoreFolders": [ "build" ], + "ignoreFiles" : [ "manifest.json", "*.png", "config.json" ], + "replacements" : { "ClientName": "Bob" }, + "showCopiedFiles": false +} +'@ | Set-Content "$FixtureRoot/config.json" + # -- extra files for later tests ----------------------------------- + if (-not (Test-Path "$FixtureRoot/src/big.bin")) { + $size = 300kb # 300 KiB > maxFileSize + $bytes = New-Object byte[] $size + [System.Random]::new().NextBytes($bytes) + [IO.File]::WriteAllBytes("$FixtureRoot/src/big.bin", $bytes) + } + + if (-not (Test-Path "$FixtureRoot/build")) { + New-Item -ItemType Directory -Path "$FixtureRoot/build" | Out-Null + 'ignore me' | Set-Content "$FixtureRoot/build/output.txt" + } + + # -- helper: run rpcp & capture what it puts on the clipboard ------- + # โ”€โ”€ helper: run rpcp & capture what it puts on the clipboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function Invoke-Rpcp { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Param + ) + + Mock Set-Clipboard { + param($Value) + # flatten any array โ†’ single string so tests are simpler + Set-Variable -Name CapturedClipboard ` + -Value ($Value -join "`n") ` + -Scope Global + } + + & $Script @Param | Out-Null + + # copy-out *before* we purge the global + $result = $CapturedClipboard + Remove-Variable -Name CapturedClipboard -Scope Global -ErrorAction SilentlyContinue + return $result +} + + } + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Context 'Default run (config.json only)' { + It 'copies only permitted files and performs replacements' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + } + + $copied | Should -Match 'include\.txt' + $copied | Should -Match 'hello Bob' + + $copied | Should -Not -Match 'manifest\.json' + $copied | Should -Not -Match 'image\.png' + $copied | Should -Not -Match 'config\.json' + $copied | Should -Not -Match 'output\.txt' + $copied | Should -Not -Match 'big\.bin' + } + } + + Context 'Max file-size filter' { + It 'excludes files bigger than maxFileSize' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" # 204 KB limit + } + + $copied | Should -Not -Match 'big\.bin' + } + + It 'includes big file when CLI overrides maxFileSize' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + MaxFileSize = 0 # disable size filtering + } + + $copied | Should -Match 'big\.bin' + } + } + + Context 'Folder ignore pattern' { + It 'skips anything inside folders named "build"' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + } + + $pattern = [regex]::Escape('build/output.txt') + $copied | Should -Not -Match $pattern + } + } + + Context 'ShowCopiedFiles switch' { + It 'still copies correctly when -ShowCopiedFiles is used' { + $copied = Invoke-Rpcp -Param @{ + RepoPath = $FixtureRoot + ConfigFile = "$FixtureRoot/config.json" + ShowCopiedFiles = $true + } + + $copied | Should -Match 'include\.txt' # sanity check + } + } +} diff --git a/tests/test_helper/bats-assert b/tests/test_helper/bats-assert new file mode 160000 index 0000000..b93143a --- /dev/null +++ b/tests/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit b93143a1bfbde41d9b7343aab0d36f3ef6549e6b diff --git a/tests/test_helper/bats-support b/tests/test_helper/bats-support new file mode 160000 index 0000000..d007fc1 --- /dev/null +++ b/tests/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit d007fc1f451abbad55204fa9c9eb3e6ed5dc5f61