diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..33c2324 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,146 @@ +--- +name: ๐Ÿ› Bug Report +about: Create a report to help us improve DevSetup +title: '[BUG] ' +labels: ['bug', 'needs-triage'] +assignees: '' +--- + +## Bug Description + +**Brief Summary** + + +**Expected Behavior** + + +**Actual Behavior** + + +## Environment Information + +**PowerShell Version** + +```powershell + +``` + +**DevSetup Version** + +```powershell + +``` + +**Operating System** + +- OS: +- Version: +- Architecture: [x64/x86/ARM] + +**Package Manager Versions** (if applicable) + +- [ ] Chocolatey: `choco --version` โ†’ +- [ ] Scoop: `scoop --version` โ†’ +- [ ] PowerShell Gallery: Available +- [ ] Homebrew: `brew --version` โ†’ + +## Reproduction Steps + +**Steps to Reproduce** +1. +2. +3. +4. + +**DevSetup Command Used** +```powershell +# Paste the exact command that caused the issue +``` + +**Environment File** (if applicable) + +```yaml + +``` + +## Error Details + +**Error Messages** + +``` +Paste error output here +``` + +**Stack Traces** (if available) + +``` +Paste stack trace here +``` + +**Log Output** (if available) + +``` +Paste log output here +``` + +## Provider-Specific Information + +**Which provider is affected?** (check all that apply) +- [ ] ๐Ÿซ Chocolatey Provider +- [ ] ๐Ÿฅ„ Scoop Provider +- [ ] ๐Ÿบ Homebrew Provider +- [ ] ๐Ÿ’Ž PowerShell Module Provider +- [ ] ๐Ÿ—๏ธ Core Dependencies (Git, Nuget) +- [ ] ๐Ÿ“‹ 3rd Party (Visual Studio, VS Code) +- [ ] ๐Ÿ“ฆ Core Commands +- [ ] ๐Ÿ”ง Utilities/Helper functions + +**Specific Package/Component** (if applicable) + + +## Additional Context + +**Screenshots** + + +**Configuration Details** + + +**Workarounds Attempted** + +- [ ] Restarted PowerShell session +- [ ] Ran as Administrator +- [ ] Used `-DryRUn` to test +- [ ] Checked package manager directly +- [ ] Cleared caches +- [ ] Other: + +**Related Issues** + + +## Impact Assessment + +**Frequency** +- [ ] Happens every time +- [ ] Happens sometimes +- [ ] Happened once +- [ ] Only in specific conditions + +**Severity** +- [ ] ๐Ÿ”ฅ Critical - Blocks all functionality +- [ ] ๐Ÿšจ High - Blocks major functionality +- [ ] โš ๏ธ Medium - Impacts some functionality +- [ ] ๐Ÿ“ Low - Minor issue or cosmetic + +**Workaround Available** +- [ ] Yes - I can work around this issue +- [ ] No - This completely blocks my progress + +--- + +**Checklist before submitting:** +- [ ] โœ… I have searched existing issues for duplicates +- [ ] โœ… I have provided all requested environment information +- [ ] โœ… I have included clear reproduction steps +- [ ] โœ… I have removed or obfuscated any sensitive information +- [ ] โœ… I have tested with the latest version of DevSetup \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d60def2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ๐Ÿ’ฌ Discussion Forum + url: https://github.com/pwshdevs/devsetup/discussions + about: Ask questions, share ideas, and discuss DevSetup with the community + - name: ๐Ÿ“– Documentation + url: https://www.pwshdevs.com/docs/devsetup + about: Read the official documentation and getting started guide + - name: ๐Ÿ” Search Issues + url: https://github.com/pwshdevs/devsetup/issues?q=is%3Aissue + about: Search existing issues before creating a new one \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..efd6151 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,177 @@ +--- +name: ๐Ÿ“š Documentation +about: Request improvements or report issues with documentation +title: '[DOCS] ' +labels: ['documentation', 'needs-review'] +assignees: '' +--- + +## Documentation Issue + +**Type of Documentation Issue** (check one) +- [ ] ๐Ÿ“ Missing documentation +- [ ] ๐Ÿ› Incorrect information +- [ ] ๐Ÿ”„ Outdated information +- [ ] ๐Ÿ˜• Unclear explanation +- [ ] ๐Ÿ’ก Enhancement suggestion +- [ ] ๐Ÿ”— Broken links +- [ ] ๐Ÿ“– Example needed +- [ ] ๐ŸŽฏ Better organization needed + +**Affected Documentation** (check all that apply) +- [ ] ๐Ÿ“˜ README.md +- [ ] ๐Ÿ“‹ CONTRIBUTING.md +- [ ] ๐Ÿ”’ SECURITY.md +- [ ] ๐Ÿ“„ Function help (Get-Help) +- [ ] ๐Ÿ“ Individual function docs (docs/*.md) +- [ ] ๐Ÿš€ Installation guide +- [ ] ๐ŸŽ“ Usage examples +- [ ] ๐Ÿ—๏ธ Architecture/design docs +- [ ] ๐Ÿ”ง Troubleshooting guide +- [ ] Other: ___________ + +**Specific Location** + +- File/Function: +- Section/Heading: +- Line numbers (if applicable): + +## Issue Description + +**What's Wrong or Missing?** + + +**Expected Information** + + +**Current Information** + + +## Context and Use Case + +**Who Would Benefit?** +- [ ] ๐Ÿ‘‹ New users getting started +- [ ] ๐Ÿ’ป Regular users +- [ ] ๐Ÿ”ง Advanced users/power users +- [ ] ๐Ÿ—๏ธ Contributors/developers +- [ ] ๐Ÿ“ฆ Provider developers +- [ ] ๐ŸŽ“ Tutorial followers + +**User Journey Context** + +- [ ] During initial setup +- [ ] While learning basic features +- [ ] When troubleshooting issues +- [ ] When contributing code +- [ ] When developing providers +- [ ] When migrating from other tools + +**Specific Scenario** + + +## Content Suggestions + +**Proposed Content** (if you have suggestions) + + +**Examples Needed** + +```powershell +# Example commands or code that should be included + +``` + +```yaml +# Example configuration that should be documented + +``` + +**Related Information** + + +## Format and Style Preferences + +**Documentation Type Needed** +- [ ] ๐Ÿ“ Conceptual explanation +- [ ] ๐Ÿ” Step-by-step tutorial +- [ ] ๐Ÿ“– Reference documentation +- [ ] โšก Quick start guide +- [ ] ๐ŸŽฏ How-to guide +- [ ] โ“ FAQ entry +- [ ] ๐Ÿ› ๏ธ Troubleshooting steps +- [ ] ๐Ÿ“Š Comparison table + +**Level of Detail** +- [ ] ๐Ÿ“š Comprehensive and detailed +- [ ] ๐Ÿ“‹ Standard level of detail +- [ ] โšก Brief and concise +- [ ] ๐ŸŽฏ Just the essentials + +**Target Audience Level** +- [ ] ๐Ÿ‘ถ Beginner (new to PowerShell/DevSetup) +- [ ] ๐Ÿ“ˆ Intermediate (familiar with basics) +- [ ] ๐ŸŽ“ Advanced (experienced user) +- [ ] ๐Ÿ”ง Expert (contributor/developer level) + +## Research and References + +**Research Done** + +- [ ] Existing documentation +- [ ] Source code comments +- [ ] Function help content +- [ ] Community discussions +- [ ] Similar tools' documentation + +**External References** + + +**Similar Examples** + + +## Impact and Priority + +**How Important is This?** +- [ ] ๐Ÿ”ฅ Critical - Blocking users from using the tool +- [ ] ๐Ÿšจ High - Causes frequent confusion or support requests +- [ ] โš ๏ธ Medium - Would improve user experience +- [ ] ๐Ÿ“ Low - Nice to have improvement + +**Frequency of Need** +- [ ] Daily - Users hit this regularly +- [ ] Weekly - Common scenario +- [ ] Monthly - Occasional need +- [ ] Rarely - Edge case scenario + +**Current Workaround** + +- [ ] Asking in discussions/issues +- [ ] Reading source code +- [ ] Trial and error +- [ ] Finding examples elsewhere +- [ ] No workaround available + +## Contribution Offer + +**How Can You Help?** +- [ ] โœ… I can draft the content +- [ ] โœ… I can provide examples +- [ ] โœ… I can review drafts +- [ ] โœ… I can test instructions +- [ ] โœ… I can provide feedback only +- [ ] โŒ I need help from maintainers + +**Content Draft** (if applicable) + + +**Additional Context** + + +--- + +**Checklist before submitting:** +- [ ] โœ… I have searched existing documentation for this information +- [ ] โœ… I have checked recent issues for similar documentation requests +- [ ] โœ… I have provided specific location information where possible +- [ ] โœ… I have described the user scenario clearly +- [ ] โœ… I have indicated how I can help improve the documentation \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..16667e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,158 @@ +--- +name: โœจ Feature Request +about: Suggest a new feature or enhancement for DevSetup +title: '[FEATURE] ' +labels: ['enhancement', 'needs-discussion'] +assignees: '' +--- + +## Feature Overview + +**Is your feature request related to a problem? Please describe.** + + +**Feature Summary** + + +**Use Case** + + +## Detailed Description + +**Proposed Solution** + + +**Provider Context** (check all that apply) +- [ ] ๐Ÿซ Chocolatey Provider Enhancement +- [ ] ๐Ÿฅ„ Scoop Provider Enhancement +- [ ] ๐Ÿบ Homebrew Provider Enhancement +- [ ] ๐Ÿ’Ž PowerShell Module Provider Enhancement +- [ ] ๐Ÿ—๏ธ Core Dependencies Enhancement +- [ ] ๐Ÿ“‹ 3rd Party Integration (VS, VS Code, etc.) +- [ ] ๐Ÿ“ฆ New Core Commands +- [ ] ๐Ÿ”ง Utility Functions +- [ ] ๐Ÿ“ Documentation/Help +- [ ] ๐Ÿ†• New Provider Request +- [ ] ๐Ÿ”„ Workflow/Process Enhancement + +**Specific Components** + +- [ ] Environment management (Get/Set/Assert-DevSetupEnv) +- [ ] Package management (Install/Uninstall functions) +- [ ] Provider management +- [ ] Configuration handling +- [ ] Logging/Status reporting +- [ ] Import/Export functionality +- [ ] 3rd party conversion +- [ ] Other: ___________ + +## Implementation Ideas + +**Suggested Approach** + + +**PowerShell Pattern Preferences** +- [ ] New CmdletBinding function +- [ ] Enhancement to existing function +- [ ] New parameter sets +- [ ] Pipeline support enhancement +- [ ] WhatIf/Confirm support +- [ ] Verbose/Debug improvements +- [ ] Help documentation +- [ ] Tab completion support + +**Configuration Requirements** + +```yaml +# Example of how this might look in .devsetup files + +``` + +**Example Usage** + +```powershell +# Example PowerShell commands showing the feature in use + +``` + +## Alternative Solutions + +**Alternatives Considered** + + +**Workarounds Currently Used** + + +**Why Existing Features Don't Meet Your Needs** + + +## Context and Priority + +**Business Case** + + +**Frequency of Use** +- [ ] Would use daily +- [ ] Would use weekly +- [ ] Would use monthly +- [ ] Would use occasionally +- [ ] One-time need + +**User Impact** +- [ ] ๐Ÿ‘ฅ Benefits many users +- [ ] ๐Ÿข Benefits enterprise users +- [ ] ๐ŸŽฏ Benefits specific use case +- [ ] ๐Ÿ”ง Quality of life improvement +- [ ] ๐Ÿš€ Performance improvement + +**Urgency Level** +- [ ] ๐Ÿ”ฅ Critical for current project +- [ ] โฐ Needed soon (within month) +- [ ] ๐Ÿ“… Would be nice to have +- [ ] ๐Ÿ’ญ Future consideration + +## Technical Considerations + +**Breaking Changes** +- [ ] This would be a breaking change +- [ ] This would be backward compatible +- [ ] Unsure about compatibility impact + +**Dependencies** (check all that apply) +- [ ] Requires new external dependencies +- [ ] Requires specific PowerShell version +- [ ] Requires specific OS features +- [ ] Requires package manager updates +- [ ] No new dependencies + +**Testing Considerations** + + +**Documentation Needs** +- [ ] New help documentation +- [ ] Update existing documentation +- [ ] Examples and tutorials +- [ ] Integration guides + +## Community Input + +**Community Interest** +- [ ] I'm willing to help implement this +- [ ] I can help with testing +- [ ] I can help with documentation +- [ ] I need help from maintainers + +**Related Requests** + + +**Research Done** + + +--- + +**Checklist before submitting:** +- [ ] โœ… I have searched existing issues for similar requests +- [ ] โœ… I have provided a clear use case and business justification +- [ ] โœ… I have considered implementation complexity +- [ ] โœ… I have provided examples of how this would work +- [ ] โœ… I have indicated my willingness to contribute \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/provider-request.md b/.github/ISSUE_TEMPLATE/provider-request.md new file mode 100644 index 0000000..b5e92a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/provider-request.md @@ -0,0 +1,231 @@ +--- +name: ๐Ÿ“ฆ Provider Request +about: Request support for a new package manager or provider +title: '[PROVIDER] Support for ' +labels: ['provider-request', 'enhancement', 'needs-research'] +assignees: '' +--- + +## Provider Information + +**Provider Name** + + +**Official Website/Repository** + + +**Package Manager Type** +- [ ] ๐Ÿ–ฅ๏ธ System Package Manager (OS-level) +- [ ] ๐ŸŒ Language-Specific Package Manager +- [ ] ๐Ÿ”ง Development Tool Manager +- [ ] ๐Ÿ“ฑ Application Store/Manager +- [ ] โ˜๏ธ Cloud-based Package Manager +- [ ] ๐Ÿณ Container-based Package Manager +- [ ] ๐Ÿข Enterprise Package Manager +- [ ] Other: ___________ + +**Supported Platforms** (check all that apply) +- [ ] ๐ŸชŸ Windows +- [ ] ๐ŸŽ macOS +- [ ] ๐Ÿง Linux +- [ ] ๐ŸŒ Cross-platform +- [ ] Specific distros: ___________ + +## Provider Details + +**Installation Method** + + +**Command Line Interface** +```bash +# Example commands for common operations +# Install package: +# Uninstall package: +# List installed: +# Update packages: +# Search packages: +``` + +**Configuration Location** + +- Config files: +- Package cache: +- Installation directory: + +**Authentication/Credentials** +- [ ] No authentication required +- [ ] API keys/tokens required +- [ ] User account required +- [ ] Enterprise authentication +- [ ] Other: ___________ + +## Use Case and Justification + +**Why is this provider needed?** + + +**User Base** + +- [ ] General developers +- [ ] Specific language community (which: _______) +- [ ] Enterprise users +- [ ] Academic users +- [ ] Specific industry/domain +- [ ] Regional users + +**Package Ecosystem Size** + +- [ ] Small (< 100 packages) +- [ ] Medium (100-1,000 packages) +- [ ] Large (1,000-10,000 packages) +- [ ] Very Large (> 10,000 packages) +- [ ] Unknown + +**Popularity/Adoption** + + +## Technical Analysis + +**Provider Command Patterns** +```bash +# Installation command pattern: + +# Uninstallation command pattern: + +# Listing command pattern: + +# Update command pattern: + +# Search command pattern: + +# Version query pattern: +``` + +**Exit Codes and Error Handling** + +- Success exit code: +- Failure patterns: +- Warning patterns: + +**Package Identification** + +- Package naming convention: +- Version format: +- Dependency specification: + +**Configuration Format** + +- [ ] JSON +- [ ] YAML +- [ ] TOML +- [ ] INI +- [ ] XML +- [ ] Custom format +- [ ] Command line only + +## DevSetup Integration Considerations + +**Provider Category Fit** + +- [ ] Package Managers (like Chocolatey, Scoop) +- [ ] Development Tools +- [ ] Language Runtimes +- [ ] System Dependencies +- [ ] New category needed: ___________ + +**Required DevSetup Functions** +- [ ] Install-[Provider]Component +- [ ] Uninstall-[Provider]Component +- [ ] Get-[Provider]Component +- [ ] Test-[Provider]Availability +- [ ] Assert-[Provider]ComponentInstalled + +**YAML Schema Requirements** +```yaml +# Example of how packages would be defined in .devsetup files +providers: + [provider-name]: + - name: package-name + version: version-spec + # any provider-specific options +``` + +**Prerequisites** + +- [ ] No prerequisites +- [ ] Provider must be pre-installed +- [ ] Specific PowerShell modules +- [ ] System-level dependencies +- [ ] Network access requirements + +## Implementation Complexity + +**Estimated Complexity** +- [ ] ๐ŸŸข Low - Similar to existing providers +- [ ] ๐ŸŸก Medium - Some unique challenges +- [ ] ๐Ÿ”ด High - Significant new patterns needed + +**Potential Challenges** + +- [ ] Complex authentication +- [ ] Non-standard command patterns +- [ ] Platform-specific behavior +- [ ] Limited CLI availability +- [ ] Requires elevated permissions +- [ ] Network/proxy complexity +- [ ] Package dependency resolution +- [ ] Version compatibility issues + +**Testing Considerations** + +- [ ] Mock testing sufficient +- [ ] Requires test environment +- [ ] Needs specific OS/platform +- [ ] Requires credentials/accounts +- [ ] Performance testing needed + +## Research and References + +**Documentation Links** + +- Official docs: +- CLI reference: +- API documentation: +- Community resources: + +**Similar Implementations** + + +**Community Interest** + + +**License Considerations** + +- Provider license: +- CLI tool license: +- Distribution restrictions: + +## Contribution Willingness + +**How can you help?** +- [ ] โœ… I can help with research and design +- [ ] โœ… I can help with implementation +- [ ] โœ… I can help with testing +- [ ] โœ… I can help with documentation +- [ ] โœ… I can provide test environment/access +- [ ] โŒ I can only provide requirements and feedback + +**Timeline Needs** +- [ ] ๐Ÿ”ฅ Urgent - needed for current project +- [ ] โฐ Soon - within next few months +- [ ] ๐Ÿ“… Future - when convenient +- [ ] ๐Ÿ’ญ Exploratory - just investigating + +--- + +**Checklist before submitting:** +- [ ] โœ… I have researched the provider's CLI capabilities +- [ ] โœ… I have checked if similar providers exist in DevSetup +- [ ] โœ… I have provided links to official documentation +- [ ] โœ… I have described the specific use case clearly +- [ ] โœ… I have indicated how I can contribute to implementation \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d0af9cc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,174 @@ +--- +name: Pull Request +about: Submit changes to the DevSetup project +title: '' +labels: '' +assignees: '' +--- + +## Summary + + + +## Type of Change + + +- [ ] ๐Ÿ› **Bug fix** (non-breaking change which fixes an issue) +- [ ] โœจ **New feature** (non-breaking change which adds functionality) +- [ ] ๐Ÿ’ฅ **Breaking change** (fix or feature that would cause existing functionality to not work as expected) +- [ ] ๐Ÿ“š **Documentation** (changes to documentation only) +- [ ] ๐Ÿงช **Tests** (adding missing tests or correcting existing tests) +- [ ] โ™ป๏ธ **Refactor** (code changes that neither fix a bug nor add a feature) +- [ ] ๐ŸŽจ **Style** (formatting, missing semi-colons, etc; no production code change) +- [ ] โšก **Performance** (code changes that improve performance) +- [ ] ๐Ÿ”ง **Chore** (updating grunt tasks, build processes, etc; no production code change) + +## Changes Made + + +- +- +- + +## Provider/Component Affected + + +- [ ] ๐Ÿ“ฆ **Core Commands** (Install-DevSetupEnv, Export-DevSetupEnv, etc.) +- [ ] ๐Ÿซ **Chocolatey Provider** (Chocolatey package management) +- [ ] ๐Ÿฅ„ **Scoop Provider** (Scoop package management) +- [ ] ๐Ÿบ **Homebrew Provider** (Homebrew package management) +- [ ] ๐Ÿ’Ž **PowerShell Provider** (PowerShell module management) +- [ ] ๐Ÿ—๏ธ **Core Dependencies** (Git repositories, Nuget packages) +- [ ] ๐Ÿ”ง **Utilities** (Helper functions, logging, validation) +- [ ] ๐Ÿ“‹ **3rd Party Integrations** (Visual Studio, VS Code) +- [ ] ๐Ÿ“– **Documentation** (README, CONTRIBUTING, etc.) +- [ ] โš™๏ธ **Build/CI** (GitHub Actions, scripts) + +## Testing + +### Test Coverage +- [ ] โœ… **All existing tests pass** (`.\runTests.ps1`) +- [ ] โœ… **New tests added** for new functionality +- [ ] โœ… **Test coverage maintained/improved** (aim for 100% on new code) +- [ ] โœ… **Security analysis passes** (`.\runSecurity.ps1`) + +### Test Types Added/Modified + +- [ ] ๐Ÿ”ง **Unit Tests** (individual function testing) +- [ ] ๐Ÿ”„ **Integration Tests** (cross-component testing) +- [ ] ๐Ÿšจ **Error Handling Tests** (exception scenarios) +- [ ] ๐ŸŽญ **Mock/Stub Tests** (external dependency mocking) +- [ ] ๐Ÿ‘€ **WhatIf/ShouldProcess Tests** (dry-run functionality) +- [ ] ๐Ÿ” **Edge Case Tests** (boundary conditions, invalid inputs) + +### Manual Testing + +- [ ] โœ… **Tested on Windows PowerShell 5.1** +- [ ] โœ… **Tested on PowerShell 7.x** +- [ ] โœ… **Tested with `-WhatIf` parameter** +- [ ] โœ… **Tested error scenarios** +- [ ] โœ… **Tested with real environment files** + +**Manual testing details:** + + +## Code Quality + +### PowerShell Best Practices +- [ ] โœ… **Uses approved verbs** (Get-, Set-, Install-, etc.) +- [ ] โœ… **Follows PascalCase** for functions and parameters +- [ ] โœ… **Includes comprehensive help documentation** +- [ ] โœ… **Uses `[CmdletBinding()]`** for advanced functions +- [ ] โœ… **Implements proper error handling** (try/catch with logging) +- [ ] โœ… **Supports WhatIf/Confirm** (where applicable) +- [ ] โœ… **Uses `Write-StatusMessage`** for consistent logging + +### Security Considerations +- [ ] โœ… **Input validation implemented** +- [ ] โœ… **No hardcoded secrets or credentials** +- [ ] โœ… **Secure error messages** (no sensitive info exposure) +- [ ] โœ… **Minimal required permissions** +- [ ] โœ… **Follows security best practices** from SECURITY.md + +## Breaking Changes + + +- **What breaks:** +- **Migration path:** +- **Deprecation notices:** + +## Related Issues + + +Fixes #(issue number) +Closes #(issue number) +Relates to #(issue number) + +## Screenshots/Output + + + +### Before +```powershell +# Show current behavior +``` + +### After +```powershell +# Show new behavior +``` + +## Checklist + +### Code Requirements +- [ ] โœ… **Code follows the project's coding standards** (see CONTRIBUTING.md) +- [ ] โœ… **Self-review completed** (checked my own PR for issues) +- [ ] โœ… **Code is properly commented** (especially complex logic) +- [ ] โœ… **No debug code or console.log statements** left in +- [ ] โœ… **Function/parameter names are descriptive** + +### Documentation Requirements +- [ ] โœ… **Help documentation updated** (if adding/changing functions) +- [ ] โœ… **CONTRIBUTING.md updated** (if changing development process) +- [ ] โœ… **README.md updated** (if changing user-facing features) +- [ ] โœ… **Examples provided** in help documentation + +### Testing Requirements +- [ ] โœ… **All tests pass locally** +- [ ] โœ… **New tests follow existing patterns** (BeforeAll/BeforeEach structure) +- [ ] โœ… **PSCustomObject used for YAML test data** (matches Assert-DevSetupEnvValid) +- [ ] โœ… **Proper mocking of external dependencies** +- [ ] โœ… **Exception handling tests included** + +### Provider-Specific (if applicable) +- [ ] โœ… **Follows provider patterns** (Install/Uninstall/Test functions) +- [ ] โœ… **Supports batch operations** with progress reporting +- [ ] โœ… **Includes cache management** (if applicable) +- [ ] โœ… **Handles simple and complex object formats** +- [ ] โœ… **Proper parameter splatting** for sub-functions + +## Additional Notes + + + +## Review Focus Areas + + +- **Security implications:** +- **Performance impact:** +- **Breaking change validation:** +- **Test coverage gaps:** +- **Documentation clarity:** + +--- + +### Reviewer Checklist + + +- [ ] ๐Ÿ” **Code review completed** +- [ ] ๐Ÿงช **Test review completed** +- [ ] ๐Ÿ“š **Documentation review completed** +- [ ] ๐Ÿ”’ **Security review completed** +- [ ] โœ… **Approved for merge** + +/cc @pwshdevs \ No newline at end of file diff --git a/.github/workflows/run-lint.yml b/.github/workflows/run-lint.yml index c8c2dd3..8f1af8e 100644 --- a/.github/workflows/run-lint.yml +++ b/.github/workflows/run-lint.yml @@ -1,10 +1,10 @@ name: Run PSScriptAnalyzer Tests on: - pull_request: - branches: - - develop - - main - workflow_dispatch: + pull_request: + branches: + - develop + - main + workflow_dispatch: jobs: psscriptanalyzer: @@ -17,7 +17,9 @@ jobs: pull-requests: write security-events: write steps: - - uses: actions/checkout@v4 + - name: Checkout Repository Code + uses: actions/checkout@v4 + - name: Lint with PSScriptAnalyzer shell: pwsh run: | diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 732a8a0..ce5bc30 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -1,10 +1,10 @@ name: Run Pester Tests on: - pull_request: - branches: - - develop - - main - workflow_dispatch: + pull_request: + branches: + - develop + - main + workflow_dispatch: jobs: pester-test-linux: @@ -20,16 +20,42 @@ jobs: steps: - name: Check out repository code uses: actions/checkout@v4 + - name: Execute runTests.ps1 shell: pwsh run: | .\runTests.ps1 + - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action/linux@v2 if: (!cancelled()) with: check_name: Pester test (On Linux) Results files: testResults.xml + - name: Setup .NET Core # Required to execute ReportGenerator + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + dotnet-quality: 'ga' + + - name: ReportGenerator + uses: danielpalme/ReportGenerator-GitHub-Action@5.4.13 + with: + reports: 'coverage.xml' + targetdir: 'coveragereport' + reporttypes: 'MarkdownSummaryGithub' + title: 'Pester Test Coverage Report' + + - name: Add comment to PR # Only applicable if 'MarkdownSummaryGithub' or one of the other Markdown report types is generated + if: github.event_name == 'pull_request' + run: gh pr comment $PR_NUMBER --edit-last --create-if-none --body-file coveragereport/SummaryGithub.md # Adjust path and filename if necessary + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.number }} + + - name: Publish coverage in build summary # Only applicable if 'MarkdownSummaryGithub' or one of the other Markdown report types is generated + run: cat coveragereport/SummaryGithub.md >> $GITHUB_STEP_SUMMARY # Adjust path and filename if necessary + shell: bash pester-test-windows: name: Pester test (On Windows) @@ -44,16 +70,34 @@ jobs: steps: - name: Check out repository code uses: actions/checkout@v4 + - name: Execute runTests.ps1 shell: pwsh run: | .\runTests.ps1 + - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action/windows@v2 if: (!cancelled()) with: check_name: Pester test (On Windows) Results files: testResults.xml + - name: Setup .NET Core # Required to execute ReportGenerator + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + dotnet-quality: 'ga' + + - name: ReportGenerator + uses: danielpalme/ReportGenerator-GitHub-Action@5.4.13 + with: + reports: 'coverage.xml' + targetdir: 'coveragereport' + reporttypes: 'MarkdownSummaryGithub' + title: 'Pester Test Coverage Report (Windows)' + - name: Publish coverage in build summary # Only applicable if 'MarkdownSummaryGithub' or one of the other Markdown report types is generated + run: cat coveragereport/SummaryGithub.md >> $GITHUB_STEP_SUMMARY # Adjust path and filename if necessary + shell: bash pester-test-macos: name: Pester test (On macOS) @@ -66,15 +110,33 @@ jobs: checks: write pull-requests: write steps: - - name: Check out repository code + - name: Checkout Repository Code uses: actions/checkout@v4 - - name: Execute runTests.ps1 + + - name: Execute runTests.ps1 with Pester shell: pwsh run: | .\runTests.ps1 - - name: Publish Test Results + + - name: Publish Pester Test Results uses: EnricoMi/publish-unit-test-result-action/macos@v2 if: (!cancelled()) with: check_name: Pester test (On macOS) Results - files: testResults.xml \ No newline at end of file + files: testResults.xml + - name: Setup .NET Core # Required to execute ReportGenerator + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + dotnet-quality: 'ga' + + - name: ReportGenerator + uses: danielpalme/ReportGenerator-GitHub-Action@5.4.13 + with: + reports: 'coverage.xml' + targetdir: 'coveragereport' + reporttypes: 'MarkdownSummaryGithub' + title: 'Pester Test Coverage Report (macOS)' + - name: Publish coverage in build summary # Only applicable if 'MarkdownSummaryGithub' or one of the other Markdown report types is generated + run: cat coveragereport/SummaryGithub.md >> $GITHUB_STEP_SUMMARY # Adjust path and filename if necessary + shell: bash \ No newline at end of file diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml index 8f42f37..18f99d6 100644 --- a/.github/workflows/tag-release.yaml +++ b/.github/workflows/tag-release.yaml @@ -3,7 +3,7 @@ name: Create Tagged Release on: push: branches: - - main # Trigger on pushes to the main branch + - main jobs: create_tagged_release: diff --git a/.github/workflows/update-module-version.yaml b/.github/workflows/update-module-version.yaml index afb9148..26a70a1 100644 --- a/.github/workflows/update-module-version.yaml +++ b/.github/workflows/update-module-version.yaml @@ -3,7 +3,7 @@ name: Update Module Version on: push: branches: - - develop # Trigger on pushes to the main branch + - develop workflow_dispatch: jobs: @@ -19,7 +19,7 @@ jobs: pull-requests: write steps: - - name: checkout + - name: Checkout Repository Code uses: actions/checkout@v2 - name: Create Version From Current Tags @@ -30,16 +30,11 @@ jobs: dry-run: true initial-version: '1.0.0' - - name: Modify the file + - name: Modify DevSetup.psd1 to have the current version run: | - perl -pi -e 's/[0-9]\.[0-9]\.[0-9]/${{ steps.version_tracker.outputs.version }}/' DevSetup/DevSetup.psd1 + perl -pi -e 's/[0-9]+\.[0-9]+\.[0-9]+/${{ steps.version_tracker.outputs.version }}/' DevSetup/DevSetup.psd1 -# - name: Commit and push changes -# uses: stefanzweifel/git-auto-commit-action@v5 -# with: -# commit_message: "Automated Release Tagging for ${{ steps.version_tracker.outputs.version }} in DevSetup.psd1" -# branch: - - name: Create Pull Request + - name: Create Branch and Pull Request uses: peter-evans/create-pull-request@v7 with: commit-message: Automated Release Tagging for ${{ steps.version_tracker.outputs.version }} in DevSetup.psd1 diff --git a/.gitignore b/.gitignore index d2fbd84..9597661 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,8 @@ coverage*.info # Visual Studio code coverage results *.coverage *.coveragexml +coverage.xml +testResults.xml # NCrunch _NCrunch_* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9258e5e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,310 @@ +# Contributing to DevSetup + +Thank you for your interest in contributing to DevSetup! This document provides guidelines and information for contributors. + +## Table of Contents +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Making Changes](#making-changes) +- [Testing](#testing) +- [Coding Standards](#coding-standards) +- [Pull Request Process](#pull-request-process) +- [Reporting Issues](#reporting-issues) + +## Code of Conduct + +By participating in this project, you are expected to uphold our code of conduct. Please be respectful and constructive in all interactions. + +## Getting Started + +### Prerequisites +- **PowerShell 5.1+** +- **Pester 5.0+** for running tests +- **Git** for version control +- A code editor with PowerShell support (recommended: VS Code with PowerShell extension) + +### First Time Setup +1. Fork the repository on GitHub +2. Clone your fork locally: + ```powershell + git clone https://github.com/pwshdevs/devsetup.git + cd devsetup + ``` +3. Add the upstream repository: + ```powershell + git remote add upstream https://github.com/pwshdevs/devsetup.git + ``` +4. Install the module in development mode: + ```powershell + .\install.ps1 -self + ``` + +## Development Setup + +### Repository Structure +``` +devsetup/ +โ”œโ”€โ”€ DevSetup/ # Main module directory +โ”‚ โ”œโ”€โ”€ DevSetup.psd1 # Module manifest +โ”‚ โ”œโ”€โ”€ DevSetup.psm1 # Module script +โ”‚ โ”œโ”€โ”€ Private/ # Private functions +โ”‚ โ”‚ โ”œโ”€โ”€ Commands/ # Main command implementations +โ”‚ โ”‚ โ”œโ”€โ”€ Providers/ # Provider-specific functions +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Chocolatey/ # Chocolatey provider +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Scoop/ # Scoop provider +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ... +โ”‚ โ”‚ โ””โ”€โ”€ Utils/ # Utility functions +โ”‚ โ””โ”€โ”€ Public/ # Public functions (module exports) +โ”œโ”€โ”€ docs/ # Documentation +โ”œโ”€โ”€ install.ps1 # Installation script +โ”œโ”€โ”€ runTests.ps1 # Test runner +โ””โ”€โ”€ runSecurity.ps1 # Security checks +``` + +### Running Tests +```powershell +# Run all tests +.\runTests.ps1 + +# Run tests for a specific file +Invoke-Pester -Path "DevSetup\Private\Providers\Scoop\*.Tests.ps1" + +# Run tests with coverage +Invoke-Pester -Path "DevSetup\Private\Providers\Scoop\*.Tests.ps1" -CodeCoverage "DevSetup\Private\Providers\Scoop\*.ps1" +``` + +### Security Analysis +```powershell +# Run security analysis +.\runSecurity.ps1 +``` + +## Making Changes + +### Branch Naming +- **Feature branches**: `feature/description-of-feature` +- **Bug fixes**: `fix/description-of-fix` +- **Documentation**: `docs/description-of-change` +- **Tests**: `test/description-of-test-change` + +### Commit Messages +Use clear, descriptive commit messages following conventional commits: +- `feat: add new scoop package provider` +- `fix: resolve chocolatey installation issue` +- `docs: update installation instructions` +- `test: add comprehensive tests for Install-ScoopPackage` +- `refactor: improve error handling in Uninstall-ScoopBucket` + +## Testing + +### Test Requirements +- **All new functions MUST have comprehensive tests** +- **Aim for 100% code coverage** on new code +- **Follow existing test patterns** in the codebase +- **Test both success and failure scenarios** +- **Include edge cases and error handling** + +### Test Structure +```powershell +BeforeAll { + # Dot-source the function and dependencies + . $PSScriptRoot\YourFunction.ps1 + . $PSScriptRoot\..\..\Utils\Write-StatusMessage.ps1 + + # Global mocks if needed + Mock Write-StatusMessage { } +} + +Describe "YourFunction" { + BeforeEach { + # Reset state before each test + $global:LASTEXITCODE = 0 + } + + Context "When normal operation succeeds" { + It "Should return expected result" { + # Test implementation + } + } + + Context "When error conditions occur" { + It "Should handle errors gracefully" { + # Test error handling + } + } +} +``` + +### Test Patterns +- **Use PSCustomObject for YAML data** to match `Assert-DevSetupEnvValid` requirements +- **Mock external dependencies** (commands, file operations, etc.) +- **Test WhatIf/ShouldProcess functionality** for functions that support it +- **Verify parameter validation** and edge cases +- **Test exception handling** with proper error logging + +## Coding Standards + +### PowerShell Best Practices +- **Use approved verbs** for function names (`Get-`, `Set-`, `Install-`, etc.) +- **Follow PascalCase** for function names and parameters +- **Use full parameter names** in scripts (avoid aliases) +- **Include comprehensive help documentation** with examples +- **Use `[CmdletBinding()]`** for advanced functions +- **Implement proper error handling** with try/catch blocks +- **Support WhatIf/Confirm** for functions that make changes + +### Function Structure +```powershell +<# +.SYNOPSIS + Brief description of what the function does. + +.DESCRIPTION + Detailed description with comprehensive information. + +.PARAMETER ParameterName + Description of the parameter. + +.OUTPUTS + [System.Type] + Description of what the function returns. + +.EXAMPLE + FunctionName -Parameter "value" + + Description of what this example does. + +.NOTES + Additional implementation notes, requirements, or caveats. +#> +Function FunctionName { + [CmdletBinding(SupportsShouldProcess=$true)] + Param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string]$ParameterName + ) + + # Implementation with proper error handling + try { + if ($PSCmdlet.ShouldProcess($target, $operation)) { + # Perform the operation + } else { + Write-StatusMessage "Skipping operation due to ShouldProcess" -Verbosity Debug + return $true + } + } catch { + Write-StatusMessage "Error message: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } +} +``` + +### Error Handling Standards +- **Use try/catch blocks** for operations that may fail +- **Log both error messages and stack traces** using `Write-StatusMessage` +- **Return boolean values** for success/failure indication +- **Continue processing** when possible (don't fail fast unless critical) + +### Provider Development +When adding new providers: +1. **Follow existing provider patterns** (see Scoop/Chocolatey examples) +2. **Implement core functions**: Install, Uninstall, Test-Installed, Find-Provider +3. **Support batch operations** with comprehensive progress reporting +4. **Include cache management** if applicable +5. **Handle both simple and complex object formats** in configurations + +## Pull Request Process + +### Before Submitting +1. **Ensure all tests pass**: `.\runTests.ps1` +2. **Run security analysis**: `.\runSecurity.ps1` +3. **Update documentation** if needed +4. **Add tests for new functionality** +5. **Follow coding standards** + +### PR Description Template +```markdown +## Description +Brief description of changes made. + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update + +## Testing +- [ ] Tests pass locally +- [ ] Added tests for new functionality +- [ ] Code coverage maintained/improved + +## Screenshots (if applicable) + +## Additional Notes +Any additional information or context. +``` + +### Review Process +1. **Automated checks** must pass (tests, security analysis) +2. **Code review** by at least one maintainer +3. **Documentation review** if docs are changed +4. **Final approval** and merge by maintainer + +## Reporting Issues + +### Bug Reports +When reporting bugs, please include: +- **PowerShell version** (`$PSVersionTable`) +- **Operating system** and version +- **Steps to reproduce** the issue +- **Expected vs actual behavior** +- **Error messages** or stack traces +- **Relevant configuration** (sanitized) + +### Feature Requests +For new features: +- **Describe the use case** and problem being solved +- **Provide examples** of how it would be used +- **Consider implementation complexity** and maintenance burden +- **Check existing issues** to avoid duplicates + +### Issue Labels +- `bug`: Something isn't working +- `enhancement`: New feature or request +- `documentation`: Improvements or additions to documentation +- `good first issue`: Good for newcomers +- `help wanted`: Extra attention is needed +- `question`: Further information is requested + +## Development Tips + +### Debugging +- Use `Write-StatusMessage` with `-Verbosity Debug` for debugging output +- Test with `-WhatIf` parameter to see what would happen without making changes +- Use `Get-DevSetupEnvList` to see available environments for testing + +### Testing Specific Providers +```powershell +# Test Scoop provider functions +Invoke-Pester -Path "DevSetup\Private\Providers\Scoop\*.Tests.ps1" -Output Detailed + +# Test with coverage +Invoke-Pester -Path "DevSetup\Private\Providers\Scoop\Install-ScoopPackage.Tests.ps1" -CodeCoverage "DevSetup\Private\Providers\Scoop\Install-ScoopPackage.ps1" +``` + +### Working with YAML Configurations +- Use `Assert-DevSetupEnvValid` structure for test data +- Create `PSCustomObject` structures rather than hashtables +- Test both simple strings and complex objects in configurations + +## Questions? + +If you have questions that aren't covered in this guide: +- Check existing [Issues](https://github.com/pwshdevs/devsetup/issues) +- Start a [Discussion](https://github.com/pwshdevs/devsetup/discussions) +- Review the [Documentation](./DevSetup/docs/) + +Thank you for contributing to DevSetup! ๐ŸŽ‰ \ No newline at end of file diff --git a/DevSetup/DevSetup.psd1 b/DevSetup/DevSetup.psd1 index 6602e1f..1e218f6 100644 --- a/DevSetup/DevSetup.psd1 +++ b/DevSetup/DevSetup.psd1 @@ -12,7 +12,7 @@ RootModule = 'DevSetup.psm1' # Version number of this module. -ModuleVersion = '1.0.9' +ModuleVersion = '1.0.10' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/DevSetup/Private/3rdParty/ConvertFrom-3rdPartyInstall.ps1 b/DevSetup/Private/3rdParty/ConvertFrom-3rdPartyInstall.ps1 index 8425ad3..8c97d84 100644 --- a/DevSetup/Private/3rdParty/ConvertFrom-3rdPartyInstall.ps1 +++ b/DevSetup/Private/3rdParty/ConvertFrom-3rdPartyInstall.ps1 @@ -1,18 +1,19 @@ Function ConvertFrom-3rdPartyInstall { Param( - [string]$Config + [string]$Config, + [switch]$DryRun ) if((Test-OperatingSystem -Windows)) { # Convert from Visual Studio installations - Write-Host "`nScanning Visual Studio installations..." -ForegroundColor Cyan - if (-not (ConvertFrom-VisualStudioInstall -Config $Config)) { + Write-Host "`nScanning for Visual Studio installations..." -ForegroundColor Cyan + if (-not (ConvertFrom-VisualStudioInstall -Config $Config -DryRun:$DryRun)) { Write-Warning "Failed to convert Visual Studio installations, but continuing..." } # Convert from Visual Studio Code installations - Write-Host "`nScanning Visual Studio Code installation..." -ForegroundColor Cyan - if (-not (ConvertFrom-VisualStudioCodeInstall -Config $Config)) { + Write-Host "`nScanning for Visual Studio Code installation..." -ForegroundColor Cyan + if (-not (ConvertFrom-VisualStudioCodeInstall -Config $Config -DryRun:$DryRun)) { Write-Warning "Failed to convert Visual Studio Code installation, but continuing..." } } diff --git a/DevSetup/Private/3rdParty/VisualStudio/Add-VsToPackageManager.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Add-VsToPackageManager.Tests.ps1 new file mode 100644 index 0000000..daa1857 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Add-VsToPackageManager.Tests.ps1 @@ -0,0 +1,193 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Add-VsToPackageManager.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Update-DevSetupEnvFile.ps1") + Mock Write-StatusMessage { } + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @() + } + } + } + } + } + Mock Update-DevSetupEnvFile { } +} + +Describe "Add-VsToPackageManager" { + + Context "When adding Visual Studio 2022 Community" { + It "Should add package and return package name" { + $instance = @{ DisplayName = "Visual Studio Community 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2022community" + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Found: Visual Studio Community 2022" -and $ForegroundColor -eq "Gray" -and $Indent -eq 2 } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Adding Visual Studio Community 2022" -and $ForegroundColor -eq "Gray" -and $Indent -eq 4 } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "[OK]" -and $ForegroundColor -eq "Green" } + } + } + + Context "When adding Visual Studio 2019 Professional" { + It "Should add package and return package name" { + $instance = @{ DisplayName = "Visual Studio Professional 2019" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2019professional" + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + } + + Context "When adding Visual Studio 2022 Enterprise" { + It "Should add package and return package name" { + $instance = @{ DisplayName = "Visual Studio Enterprise 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2022enterprise" + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + } + + Context "When package already exists as string" { + It "Should return package name without updating" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @("visualstudio2022community") + } + } + } + } + } + $instance = @{ DisplayName = "Visual Studio Community 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2022community" + Assert-MockCalled Update-DevSetupEnvFile -Exactly 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Visual Studio is already listed as a chocolatey package." -and $Verbosity -eq "Debug" } + } + } + + Context "When package already exists as hashtable" { + It "Should return package name without updating" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @(@{ name = "visualstudio2022community"; version = "17.0" }) + } + } + } + } + } + $instance = @{ DisplayName = "Visual Studio Community 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2022community" + Assert-MockCalled Update-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "When display name has no year" { + It "Should write warning and return null" { + $instance = @{ DisplayName = "Visual Studio Community" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Unable to determine Visual Studio year" -and $ForegroundColor -eq "Yellow" -and $Indent -eq 4 } + Assert-MockCalled Update-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "When display name has no type" { + It "Should write warning and return null" { + $instance = @{ DisplayName = "Visual Studio 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Unable to determine Visual Studio type" -and $ForegroundColor -eq "Yellow" -and $Indent -eq 4 } + Assert-MockCalled Update-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "When Read-DevSetupEnvFile returns empty data" { + It "Should create structure and add package" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{} + dependencies = @{} + commands = @() + } + } + } + $instance = @{ DisplayName = "Visual Studio Community 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2022community" + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + } + + Context "When Update-DevSetupEnvFile throws exception" { + It "Should write error and return null" { + Mock Update-DevSetupEnvFile { throw "Update failed" } + $instance = @{ DisplayName = "Visual Studio Community 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error updating DevSetup environment file" -and $Verbosity -eq "Error" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Green" } + } + } + + Context "When DryRun is specified" { + It "Should call Update-DevSetupEnvFile with WhatIf" { + $instance = @{ DisplayName = "Visual Studio Community 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile -DryRun + $result | Should -Be "visualstudio2022community" + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $WhatIf -eq $true } + } + } + + Context "When instance is null" { + It "Should throw due to parameter validation" { + { Add-VsToPackageManager -Instance $null -Config "$TestDrive\config.yaml" } | Should -Throw + } + } + + Context "When config is empty" { + It "Should throw due to parameter validation" { + $instance = @{ DisplayName = "Visual Studio Community 2022" } + { Add-VsToPackageManager -Instance $instance -Config "" } | Should -Throw + } + } + + Context "When display name has multiple years" { + It "Should use the first matched year" { + $instance = @{ DisplayName = "Visual Studio 2022 Community 2019" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2022community" + } + } + + Context "When display name has mixed case" { + It "Should match type case-insensitively" { + $instance = @{ DisplayName = "visual studio PROFESSIONAL 2022" } + $configFile = "$TestDrive\config.yaml" + $result = Add-VsToPackageManager -Instance $instance -Config $configFile + $result | Should -Be "visualstudio2022professional" + } + } +} diff --git a/DevSetup/Private/3rdParty/VisualStudio/Add-VsToPackageManager.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Add-VsToPackageManager.ps1 new file mode 100644 index 0000000..c83000d --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Add-VsToPackageManager.ps1 @@ -0,0 +1,79 @@ +Function Add-VsToPackageManager { + [CmdletBinding()] + [OutputType([string])] + Param( + [Parameter(Mandatory=$true)] + $Instance, + [Parameter(Mandatory=$true)] + [string]$Config, + [switch]$DryRun + ) + + $YamlData = Read-DevSetupEnvFile -Config $Config + + # Ensure chocolatey-specific sections exist + if (-not $YamlData.devsetup.dependencies.chocolatey) { $YamlData.devsetup.dependencies.chocolatey = @{} } + if (-not $YamlData.devsetup.dependencies.chocolatey.packages) { $YamlData.devsetup.dependencies.chocolatey.packages = @() } + + Write-StatusMessage "- Found: $($instance.DisplayName)" -ForegroundColor Gray -Indent 2 + + # Convert display name to Chocolatey package name + # Extract year and type separately to ensure correct ordering + $displayName = $instance.DisplayName + $year = $null + if ($displayName -match '(\d{4})') { + $year = $matches[1] + } + + if (-not $year) { + Write-StatusMessage "- Unable to determine Visual Studio year from display name: $displayName" -ForegroundColor Yellow -Indent 4 + return $null + } + + $type = $null + if ($displayName -match 'Community') { + $type = 'community' + } elseif ($displayName -match 'Professional') { + $type = 'professional' + } elseif ($displayName -match 'Enterprise') { + $type = 'enterprise' + } + + if (-not $type) { + Write-StatusMessage "- Unable to determine Visual Studio type from display name: $displayName" -ForegroundColor Yellow -Indent 4 + return $null + } + + # Build package name as visualstudio + $packageName = "visualstudio$year$type" + + Write-StatusMessage "- Adding $displayName to package manager..." -ForegroundColor Gray -Indent 4 -NoNewLine -Width 112 + + $existingPackage = $YamlData.devsetup.dependencies.chocolatey.packages | Where-Object { + ($_ -is [string] -and $_ -eq $packageName) -or + ($_.name -eq $packageName) + } + + if ($existingPackage) { + Write-StatusMessage "[OK]" -ForegroundColor Green + Write-StatusMessage "Visual Studio is already listed as a chocolatey package." -Verbosity Debug + return $packageName + } else { + # Add new package with components + $YamlData.devsetup.dependencies.chocolatey.packages += @{ + name = $packageName + version = $null + } + } + + try { + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun + Write-StatusMessage "[OK]" -ForegroundColor Green + return $packageName + } catch { + Write-StatusMessage "Error updating DevSetup environment file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + Write-StatusMessage "[FAILED]" -ForegroundColor Green + return $null + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudio/ConvertFrom-VisualStudioInstall.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudio/ConvertFrom-VisualStudioInstall.Tests.ps1 new file mode 100644 index 0000000..e3afaf6 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/ConvertFrom-VisualStudioInstall.Tests.ps1 @@ -0,0 +1,191 @@ +BeforeAll { + Function Get-VSSetupInstance {} + . (Join-Path $PSScriptRoot "ConvertFrom-VisualStudioInstall.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Update-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Test-RunningAsAdmin.ps1") + . (Join-Path $PSScriptRoot "Add-VsToPackageManager.ps1") + . (Join-Path $PSScriptRoot "Invoke-VsConfigExport.ps1") + Mock Write-StatusMessage { } + Mock Test-RunningAsAdmin { return $true } + Mock Get-VSSetupInstance { return @(@{ DisplayName = "Visual Studio Community 2022"; InstallationPath = "$TestDrive\VS2022" }) } + Mock Add-VsToPackageManager { return "visualstudio2022community" } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ commands = @() } } } + Mock Invoke-VsConfigExport { return "mocked config content" } + Mock Update-DevSetupEnvFile { } + Mock Test-RunningAsAdmin { return $true } +} + +Describe "ConvertFrom-VisualStudioInstall" { + + Context "When not running as admin" { + It "Should throw error and return false" { + Mock Test-RunningAsAdmin { return $false } + $result = ConvertFrom-VisualStudioInstall -Config "$TestDrive\config.yaml" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "requires administrator privileges" -and $Verbosity -eq "Error" } + } + } + + Context "When no VS instances found" { + It "Should write warning and return true" { + Mock Get-VSSetupInstance { return @() } + $result = ConvertFrom-VisualStudioInstall -Config "$TestDrive\config.yaml" + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "No Visual Studio instances found." -and $Verbosity -eq "Warning" } + Assert-MockCalled Add-VsToPackageManager -Exactly 0 -Scope It + } + } + + Context "When single VS instance and new command" { + It "Should add package and command, return true" { + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $true + Assert-MockCalled Get-VSSetupInstance -Exactly 1 -Scope It + Assert-MockCalled Add-VsToPackageManager -Exactly 1 -Scope It + Assert-MockCalled Invoke-VsConfigExport -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Adding new VS configuration command" -and $ForegroundColor -eq "Gray" -and $Indent -eq 4 } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Visual Studio installation conversion completed!" -and $ForegroundColor -eq "Green" } + } + } + + Context "When existing command found" { + It "Should update existing command" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + commands = @(@{ + packageName = "invoke.vs.config.import.visualstudio2022community" + command = "old command" + params = @{} + }) + } + } + } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Updating existing VS configuration command" -and $ForegroundColor -eq "Gray" -and $Indent -eq 4 } + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + } + + Context "When old command package name exists" { + It "Should update old command" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + commands = @(@{ + packageName = "visualstudio2022community.importConfig" + command = "old command" + params = @{} + }) + } + } + } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Updating existing VS configuration command" -and $ForegroundColor -eq "Gray" -and $Indent -eq 4 } + } + } + + Context "When multiple VS instances" { + It "Should process each instance" { + Mock Get-VSSetupInstance { + return @( + @{ DisplayName = "Visual Studio Community 2022"; InstallationPath = "$TestDrive\VS2022" }, + @{ DisplayName = "Visual Studio Professional 2019"; InstallationPath = "$TestDrive\VS2019" } + ) + } + Mock Add-VsToPackageManager { + param($Instance) + if ($Instance.DisplayName -match "2022") { return "visualstudio2022community" } + else { return "visualstudio2019professional" } + } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $true + Assert-MockCalled Add-VsToPackageManager -Exactly 2 -Scope It + Assert-MockCalled Invoke-VsConfigExport -Exactly 2 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 2 -Scope It + } + } + + Context "When Update-DevSetupEnvFile throws exception" { + It "Should write error and return false" { + Mock Update-DevSetupEnvFile { throw "Update failed" } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to save configuration" -and $Verbosity -eq "Error" } + } + } + + Context "When DryRun is specified" { + It "Should pass DryRun to functions" { + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile -DryRun + $result | Should -Be $true + Assert-MockCalled Add-VsToPackageManager -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $WhatIf -eq $true } + } + } + + Context "When Read-DevSetupEnvFile returns empty data" { + It "Should create commands structure" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{} + dependencies = @{} + commands = @() + } + } + } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + } + + Context "When Invoke-VsConfigExport returns null" { + It "Should still add command with null config" { + Mock Invoke-VsConfigExport { return $null } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + } + + Context "When config is empty" { + It "Should throw due to parameter validation" { + { ConvertFrom-VisualStudioInstall -Config "" } | Should -Throw + } + } + + Context "When Test-RunningAsAdmin throws exception" { + It "Should catch and return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error in Visual Studio installation conversion" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-VSSetupInstance throws exception" { + It "Should catch and return false" { + Mock Get-VSSetupInstance { throw "VS detection failed" } + $configFile = "$TestDrive\config.yaml" + $result = ConvertFrom-VisualStudioInstall -Config $configFile + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error in Visual Studio installation conversion" -and $Verbosity -eq "Error" } + } + } +} diff --git a/DevSetup/Private/3rdParty/VisualStudio/ConvertFrom-VisualStudioInstall.ps1 b/DevSetup/Private/3rdParty/VisualStudio/ConvertFrom-VisualStudioInstall.ps1 index d619c33..ec82fe5 100644 --- a/DevSetup/Private/3rdParty/VisualStudio/ConvertFrom-VisualStudioInstall.ps1 +++ b/DevSetup/Private/3rdParty/VisualStudio/ConvertFrom-VisualStudioInstall.ps1 @@ -2,7 +2,6 @@ Function ConvertFrom-VisualStudioInstall { Param( [Parameter(Mandatory=$true)] [string]$Config, - [string]$OutFile, [switch]$DryRun ) @@ -13,137 +12,81 @@ Function ConvertFrom-VisualStudioInstall { } # Get Visual Studio instances - Write-Host "- Detecting Visual Studio installations..." -ForegroundColor Gray + Write-StatusMessage "- Detecting Visual Studio installations..." -ForegroundColor Gray $vsInstances = Get-VSSetupInstance if (-not $vsInstances) { - Write-Warning "No Visual Studio instances found." + Write-StatusMessage "No Visual Studio instances found." -Verbosity Warning return $true } - - # Read existing YAML configuration - $YamlData = Read-ConfigurationFile -Config $Config - - # Ensure chocolateyPackages section exists - if (-not $YamlData.devsetup) { $YamlData.devsetup = @{} } - if (-not $YamlData.devsetup.commands) { $YamlData.devsetup.commands = @() } - if (-not $YamlData.devsetup.dependencies) { $YamlData.devsetup.dependencies = @{} } - if (-not $YamlData.devsetup.dependencies.chocolatey) { $YamlData.devsetup.dependencies.chocolatey = @{} } - if (-not $YamlData.devsetup.dependencies.chocolatey.packages) { $YamlData.devsetup.dependencies.chocolatey.packages = @() } foreach ($instance in $vsInstances) { - Write-Host " - Found: $($instance.DisplayName)" -ForegroundColor Gray + $packageName = Add-VsToPackageManager -Instance $instance -Config $Config -DryRun:$DryRun - # Convert display name to Chocolatey package name - # Extract year and type separately to ensure correct ordering - $displayName = $instance.DisplayName - $year = if ($displayName -match '(\d{4})') { $matches[1] } else { '' } - $type = '' - if ($displayName -match 'Community') { $type = 'community' } - elseif ($displayName -match 'Professional') { $type = 'professional' } - elseif ($displayName -match 'Enterprise') { $type = 'enterprise' } - - # Build package name as visualstudio - $packageName = "visualstudio$year$type" - - Write-Host " - Converted to Chocolatey package: $packageName" -ForegroundColor Gray + # Read existing YAML configuration + $YamlData = Read-DevSetupEnvFile -Config $Config + + # Ensure commands section exists + if (-not $YamlData.devsetup.commands) { $YamlData.devsetup.commands = @() } # Create temporary file for Visual Studio configuration export - $base64Config = Export-VssConfig -VssInstallPath $instance.InstallationPath + $VsConfig = Invoke-VsConfigExport -VsInstallPath $instance.InstallationPath # Create command string for importing the VS configuration - $Command = "Import-VssConfig -EncodedConfigFile '$base64Config' -VssInstallPath '$($instance.InstallationPath)'" - $commandPackageName = "$packageName.importConfig" + $Command = "Invoke-VsConfigImport" + $Params = @{ + config = $VsConfig + vsinstallpath = $instance.InstallationPath + } + $oldCommandPackageName = "$packageName.importConfig" + $CommandPackageName = "invoke.vs.config.import.$packageName" # Check if command already exists for this package $existingCommand = $YamlData.devsetup.commands | Where-Object { - ($_ -is [hashtable] -and $_.packageName -eq $commandPackageName) + ($_.packageName -eq $oldCommandPackageName -or $_.packageName -eq $CommandPackageName) } if ($existingCommand) { - Write-Host " - Updating existing VS configuration command..." -ForegroundColor Gray - + Write-StatusMessage "- Updating existing VS configuration command..." -ForegroundColor Gray -Indent 4 -NoNewline -Width 112 + # Find index of existing command $commandIndex = $YamlData.devsetup.commands.IndexOf($existingCommand) # Update with new command $YamlData.devsetup.commands[$commandIndex] = @{ - packageName = $commandPackageName + packageName = $CommandPackageName command = $Command + params = $Params } } else { - Write-Host " - Adding new VS configuration command..." -ForegroundColor Gray - + Write-StatusMessage "- Adding new VS configuration command..." -ForegroundColor Gray -Indent 4 -NoNewline -Width 112 + # Add new command $YamlData.devsetup.commands += @{ - packageName = $commandPackageName + packageName = $CommandPackageName command = $Command + params = $Params } } - $existingPackage = $YamlData.devsetup.dependencies.chocolatey.packages | Where-Object { - ($_ -is [string] -and $_ -eq $packageName) -or - ($_ -is [hashtable] -and $_.name -eq $packageName) - } - - if ($existingPackage) { - Write-Host " - Updating existing Visual Studio packages..." -ForegroundColor Gray - - # Find index of existing package - $index = $YamlData.devsetup.dependencies.chocolatey.packages.IndexOf($existingPackage) - - # Update with components - $YamlData.devsetup.dependencies.chocolatey.packages[$index] = @{ - name = $packageName - version = $null - } - } else { - Write-Host " - Adding new Visual Studio package..." -ForegroundColor Gray - - # Add new package with components - $YamlData.devsetup.dependencies.chocolatey.packages += @{ - name = $packageName - version = $null - } - } - } - - try { - $yamlOutput = $YamlData | ConvertTo-Yaml - } - catch { - Write-Warning "Could not convert to YAML format. Showing PowerShell object instead:" - $yamlOutput = $YamlData | ConvertTo-Json -Depth 10 - } - - # Handle output based on parameters - if ($DryRun) { - Write-Host "`nDry Run - Configuration would be saved as:" -ForegroundColor Cyan - Write-Host $yamlOutput -ForegroundColor White - Write-Host "`nNo files were modified (dry run mode)." -ForegroundColor Yellow - } else { - # Determine output file - $outputFile = if ($OutFile) { $OutFile } else { $Config } - try { - Write-Debug "`nSaving configuration to: $outputFile" - $yamlOutput | Out-File -FilePath $outputFile -Encoding UTF8 - Write-Debug "Configuration saved successfully!" + Write-StatusMessage "`nSaving configuration to: $Config" -Verbosity Debug + Write-StatusMessage "[OK]" -ForegroundColor Green + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun } catch { - Write-Error "Failed to save configuration to $outputFile`: $_" + Write-StatusMessage "Failed to save configuration to $Config`: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false - } + } } - # "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" export --installPath "" --config ".vsconfig" - # "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "C:\Program Files\Microsoft Visual Studio\\" --config "C:\Path\To\Your\Config.vsconfig" --passive --allowUnsignedExtensions - - Write-Host "Visual Studio installation conversion completed!" -ForegroundColor Green + Write-StatusMessage "Visual Studio installation conversion completed!" -ForegroundColor Green return $true } catch { - Write-Error "Error in Visual Studio installation conversion: $_" + Write-StatusMessage "Error in Visual Studio installation conversion: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } } \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudio/Export-VssConfig.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Export-VssConfig.ps1 deleted file mode 100644 index 7033535..0000000 --- a/DevSetup/Private/3rdParty/VisualStudio/Export-VssConfig.ps1 +++ /dev/null @@ -1,59 +0,0 @@ -Function Export-VssConfig { - param ( - [string]$VssInstallPath - ) - - if (-not (Test-Path -Path $VssInstallPath)) { - Write-Error "Visual Studio installation path not found: $VssInstallPath" - return $false - } - - try { - $tempConfigFile = [System.IO.Path]::GetTempFileName() + ".vsconfig" - - # Execute the command - & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" export --installPath $VssInstallPath --config "$tempConfigFile" --passive - - # Since setup.exe is async, wait for the config file to be created and populated - $timeout = 60 # seconds - $elapsed = 0 - $pollInterval = 2 # seconds - - Write-Host " - Waiting for Visual Studio export to complete." -ForegroundColor Gray -NoNewline - - while ($elapsed -lt $timeout) { - if ((Test-Path -Path $tempConfigFile) -and (Get-Item $tempConfigFile).Length -gt 0) { - Write-Host "`n - Export completed successfully." -ForegroundColor Gray - break - } - Start-Sleep -Seconds $pollInterval - $elapsed += $pollInterval - Write-Host "." -NoNewline -ForegroundColor Gray - } - - # Check if we timed out - if ($elapsed -ge $timeout) { - Write-Host " - Export operation timed out after $timeout seconds." -ForegroundColor Gray - Write-Warning "Visual Studio export may still be running in the background. Check the installation manually." - } - - if (-not (Test-Path -Path $tempConfigFile)) { - Write-Error "Failed to export Visual Studio configuration to temporary file." - return $false - } - - $encodedConfig = ConvertTo-Base64 -FilePath $tempConfigFile - if (-not $encodedConfig) { - Write-Error "Failed to convert configuration file to Base64." - return $false - } - - # Clean up temporary files - if (Test-Path $tempConfigFile) { Remove-Item $tempConfigFile -Force } - - return $encodedConfig - } catch { - Write-Error "Failed to export configuration to file: $_" - return $false - } -} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudio/Import-VssConfig.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Import-VssConfig.ps1 deleted file mode 100644 index 268f5fb..0000000 --- a/DevSetup/Private/3rdParty/VisualStudio/Import-VssConfig.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -Function Import-VssConfig { - param ( - [string]$EncodedConfigFile, - [string]$VssInstallPath - ) - - if (-not $EncodedConfigFile) { - Write-Error "Encoded configuration file is empty." - return $false - } - - try { - # Decode the base64 encoded configuration - $decodedConfig = ConvertFrom-Base64 -EncodedString $EncodedConfigFile - - # Create config file in user's home directory - $configFile = Join-Path -Path $env:USERPROFILE -ChildPath ".vssconfig-devsetup" - - # Write the decoded configuration to the config file - $decodedConfig | Out-File -FilePath $configFile -Encoding UTF8 - - Write-Host "Visual Studio configuration saved to: $configFile" -ForegroundColor Green - - # Run the Visual Studio installer with the config file (suppress output) - & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath $VssInstallPath --config "$configFile" --passive --allowUnsignedExtensions > $null 2>&1 - - return $true - } - catch { - Write-Error "Failed to process Visual Studio configuration: $_" - return $false - } -} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigExport.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigExport.Tests.ps1 new file mode 100644 index 0000000..e08c62c --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigExport.Tests.ps1 @@ -0,0 +1,382 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-VsConfigExport.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Get-EnvironmentVariable.ps1") + . (Join-Path $PSScriptRoot "Wait-ForVisualStudioConfigFile.ps1") + Mock Write-StatusMessage { } + Mock Get-EnvironmentVariable { "$TestDrive\UserProfile" } + Mock Remove-Item { } + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 0 + } + Mock Wait-ForVisualStudioConfigFile { $true } + Mock Get-Content { "mocked config content" } + Mock Test-Path { + $true + } +} + +Describe "Invoke-VsConfigExport" { + + Context "When VS install path not found" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $false + } + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio installation path not found" -and $Verbosity -eq "Error" } + } + } + + Context "When user profile path not found" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $true + } + 1 { + $script:testPathCallCount++ + return $false + } + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "User profile path not found" -and $Verbosity -eq "Error" } + } + } + + Context "When getting user profile fails" { + It "Should return null and write error" { + Mock Get-EnvironmentVariable { throw "Env failed" } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get user profile path" -and $Verbosity -eq "Error" } + } + } + + Context "When removing temp config file fails" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + Mock Remove-Item { throw "Remove failed" } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to remove existing temporary config file" -and $Verbosity -eq "Error" } + } + } + + Context "When setup command not found" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $true + } + 1 { + $script:testPathCallCount++ + return $true + } + 2 { + $script:testPathCallCount++ + return $true + } + 3 { + $script:testPathCallCount++ + return $false + } + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio setup command not found" -and $Verbosity -eq "Error" } + } + } + + Context "When setup command not found and test-path throws" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $true + } + 1 { + $script:testPathCallCount++ + return $true + } + 2 { + $script:testPathCallCount++ + return $true + } + 3 { + $script:testPathCallCount++ + throw "Path error" + } + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to verify Visual Studio setup command" -and $Verbosity -eq "Error" } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { $Verbosity -eq "Error" } + } + } + + Context "When export command fails with non-zero exit code" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 1 + } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio configuration export failed with exit code" -and $Verbosity -eq "Error" } + } + } + + Context "When Invoke-Command throws exception" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + Mock Invoke-Command { throw "Command failed" } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to run Visual Studio setup command" -and $Verbosity -eq "Error" } + } + } + + Context "When waiting for config file times out" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + Mock Wait-ForVisualStudioConfigFile { $false } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Timed out waiting for Visual Studio configuration export" -and $Verbosity -eq "Error" } + } + } + + Context "When temp config file not found after export" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $true + } + 1 { + $script:testPathCallCount++ + return $true + } + 2 { + $script:testPathCallCount++ + return $true + } + 3 { + $script:testPathCallCount++ + return $true + } + 4 { + $script:testPathCallCount++ + return $false + } + } + } + } + It "Should return null and write error" { + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to export Visual Studio configuration to temporary file" -and $Verbosity -eq "Error" } + } + } + + Context "When reading config file fails" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + Mock Get-Content { throw "Read failed" } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to read exported configuration file" -and $Verbosity -eq "Error" } + } + } + + Context "When exported config is empty" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return null and write error" { + Mock Get-Content { "" } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Exported Visual Studio configuration file is empty." -and $Verbosity -eq "Error" } + } + } + + Context "When export succeeds" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return config content and clean up" { + Mock Invoke-Command { + $script:LASTEXITCODE = 0 + } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + Assert-MockCalled Test-Path -Exactly 6 -Scope It # for all path checks + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Result" -and $Verbosity -eq "Debug" } + Assert-MockCalled Remove-Item -Exactly 2 -Scope It # for temp file removal + $result | Should -Be "mocked config content" + } + } + + Context "When cleanup fails" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 2 { + $script:testPathCallCount++ + return $false + } + default { + $script:testPathCallCount++ + return $true + } + } + } + } + It "Should return config and write warning" { + Mock Remove-Item { throw "Cleanup failed" } + Mock Invoke-Command { + $script:LASTEXITCODE = 0 + } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 6 -Scope It # for all path checks + Assert-MockCalled Remove-Item -Exactly 1 -Scope It # for temp file removal + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to remove temporary config file" -and $Verbosity -eq "Warning" } + $result | Should -Be "mocked config content" + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be "mocked config content" + } + + It "Should fail on Linux" { + Mock Test-Path { $false } -ParameterFilter { $Path -eq "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + } + + It "Should fail on macOS" { + Mock Test-Path { $false } -ParameterFilter { $Path -eq "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" } + $result = Invoke-VsConfigExport -VsInstallPath "$TestDrive\VS" + $result | Should -Be $null + } + } +} diff --git a/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigExport.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigExport.ps1 new file mode 100644 index 0000000..199122a --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigExport.ps1 @@ -0,0 +1,100 @@ +Function Invoke-VsConfigExport { + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory=$true, Position=0)] + [ValidateNotNullOrEmpty()] + [string]$VsInstallPath + ) + + if (-not (Test-Path -Path $VsInstallPath)) { + Write-StatusMessage "Visual Studio installation path not found: $VsInstallPath" -Verbosity Error + return $null + } + + try { + $UserProfilePath = (Get-EnvironmentVariable USERPROFILE) + if (-not (Test-Path -Path $UserProfilePath)) { + Write-StatusMessage "User profile path not found: $UserProfilePath" -Verbosity Error + return $null + } + } catch { + Write-StatusMessage "Failed to get user profile path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + # Ensure no leftover temp config file exists + $tempConfigFile = Join-Path $UserProfilePath "vsconfig.devsetup" + if (Test-Path $tempConfigFile) { + Remove-Item $tempConfigFile -Force + } + } catch { + Write-StatusMessage "Failed to remove existing temporary config file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $Command = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" + if (-not (Test-Path -Path $Command)) { + Write-StatusMessage "Visual Studio setup command not found: $Command" -Verbosity Error + return $null + } + } catch { + Write-StatusMessage "Failed to verify Visual Studio setup command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $exportStatus = Invoke-Command -ScriptBlock { & $Command export --installPath $VsInstallPath --config "$tempConfigFile" --passive } + Write-StatusMessage "Result: $exportStatus" -Verbosity Debug + if ($LASTEXITCODE -ne 0) { + Write-StatusMessage "Visual Studio configuration export failed with exit code $LASTEXITCODE." -Verbosity Error + return $null + } + } catch { + Write-StatusMessage "Failed to run Visual Studio setup command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + $tempConfigFileStatus = (Wait-ForVisualStudioConfigFile -ConfigFilePath $tempConfigFile -TimeoutSeconds 60) + + if (-not $tempConfigFileStatus) { + Write-StatusMessage "Timed out waiting for Visual Studio configuration export to complete." -Verbosity Error + return $null + } + + if (-not (Test-Path -Path $tempConfigFile)) { + Write-StatusMessage "Failed to export Visual Studio configuration to temporary file." -Verbosity Error + return $null + } + + try { + # Read the exported configuration + $Config = Get-Content -Path $tempConfigFile -Raw + } catch { + Write-StatusMessage "Failed to read exported configuration file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + if([string]::IsNullOrWhiteSpace($Config)) { + Write-StatusMessage "Exported Visual Studio configuration file is empty." -Verbosity Error + return $null + } + + # Clean up temporary files + try { + if (Test-Path $tempConfigFile) { + Remove-Item $tempConfigFile -Force + } + } catch { + Write-StatusMessage "Failed to remove temporary config file: $_" -Verbosity Warning + } + + return $Config +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigImport.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigImport.Tests.ps1 new file mode 100644 index 0000000..426b501 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigImport.Tests.ps1 @@ -0,0 +1,215 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-VsConfigImport.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Get-EnvironmentVariable.ps1") + Mock Write-StatusMessage { } + Mock Get-EnvironmentVariable { "$TestDrive\Users\TestUser" } + Mock Remove-Item { } + Mock Set-Content { } +} + +Describe "Invoke-VsConfigImport" { + + Context "When config is empty" { + It "Should throw when config is empty" { + { Invoke-VsConfigImport -Config "" -VsInstallPath "$TestDrive\VS" } | Should -Throw + } + } + + Context "When user profile path not found" { + It "Should return false and write error" { + Mock Test-Path { $false } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "User profile path not found" -and $Verbosity -eq "Error" } + } + } + + Context "When getting user profile fails" { + It "Should return false and write error" { + Mock Get-EnvironmentVariable { throw "Env failed" } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get user profile path" -and $Verbosity -eq "Error" } + } + } + + Context "When config file removal fails" { + It "Should return false and write error" { + Mock Test-Path { $true } + Mock Remove-Item { throw "Remove failed" } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to create config file path" -and $Verbosity -eq "Error" } + } + } + + Context "When writing config to file fails on psv6+" { + It "Should return false and write error" { + Mock Test-Path { $true } + Mock Set-Content { throw "Write failed" } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to write configuration to file" -and $Verbosity -eq "Error" } + } + } + + Context "When writing config to file on psv5 fails" { + It "Should return false and write error" { + Mock Test-Path { $true } + Mock Set-Content { throw "Write failed" } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to write configuration to file" -and $Verbosity -eq "Error" } + } + } + + Context "When VS install path not found" { + It "Should return false and write error" { + Mock Test-Path { param($Path) + if ($Path -like "*TestUser*") { return $true } # User profile exists + if ($Path -like "*.vssconfig*") { return $true } # Config file exists after writing + if ($Path -eq "$TestDrive\VS") { return $false } # VS install path doesn't exist + if ($Path -like "*setup.exe*") { return $true } # Setup exists + return $true + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio installation path not found" -and $Verbosity -eq "Error" } + } + } + + Context "When config file not found after writing" { + It "Should return false and write error" { + Mock Test-Path { param($Path) + if ($Path -like "*.vssconfig*") { + return $false # Config file doesn't exist after writing + } + if ($Path -like "*TestUser*" -and $Path -notlike "*.vssconfig*") { + return $true # User profile exists + } + if ($Path -eq "$TestDrive\VS") { + return $true # VS install path exists + } + if ($Path -like "*setup.exe*") { + return $true # Setup exists + } + return $true + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Configuration file not found" -and $Verbosity -eq "Error" } + } + } + + Context "When setup command not found" { + It "Should return false and write error" { + Mock Test-Path { param($Path) + if ($Path -like "*TestUser*") { return $true } # User profile exists + if ($Path -eq "$TestDrive\VS") { return $true } # VS install path exists + if ($Path -like "*.vssconfig*") { return $true } # Config file exists + if ($Path -like "*setup.exe*") { return $false } # Setup command doesn't exist + return $true + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio setup command not found" -and $Verbosity -eq "Error" } + } + } + + Context "When installer succeeds" { + It "Should return true and write success messages" { + Mock Test-Path { $true } # All paths exist + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 0 + return "Installation successful" + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $true + Assert-MockCalled Set-Content -Exactly 1 -Scope It -ParameterFilter { $Path -like "*.vssconfig*" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio configuration saved to" -and $ForegroundColor -eq "Green" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Running Visual Studio installer..." -and $Verbosity -eq "Debug" } + } + } + + Context "When installer succeeds on psv5" { + It "Should return true and write success messages" { + Mock Test-Path { $true } # All paths exist + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 0 + return "Installation successful" + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $true + Assert-MockCalled Set-Content -Exactly 1 -Scope It -ParameterFilter { $Path -like "*.vssconfig*" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio configuration saved to" -and $ForegroundColor -eq "Green" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Running Visual Studio installer..." -and $Verbosity -eq "Debug" } + } + } + + Context "When installer fails with non-zero exit code" { + It "Should return false and write error" { + Mock Test-Path { $true } # All paths exist + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 1 + return "Installation failed" + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Visual Studio configuration import failed with exit code" -and $Verbosity -eq "Error" } + } + } + + Context "When installer succeeds with zero exit code but no success message" { + It "Should return true" { + Mock Test-Path { $true } # All paths exist + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 0 + return "" + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $true + } + } + + Context "When installer succeeds with zero exit code with success message" { + It "Should return true" { + Mock Test-Path { $true } # All paths exist + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 0 + return "works" + } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $true + } + } + + Context "When Invoke-Command throws exception" { + It "Should return false and write error" { + Mock Test-Path { $true } # All paths exist + Mock Invoke-Command { throw "Command failed" } + $result = Invoke-VsConfigImport -Config "test config" -VsInstallPath "$TestDrive\VS" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to process Visual Studio configuration" -and $Verbosity -eq "Error" } + } + } + + Context "When config is piped" { + It "Should accept pipeline input and return true" { + Mock Test-Path { $true } # All paths exist + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 0 + return "Installation successful" + } + $config = "test config" + $result = ($config | Invoke-VsConfigImport -VsInstallPath "$TestDrive\VS") + $result | Should -Be $true + } + } +} diff --git a/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigImport.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigImport.ps1 new file mode 100644 index 0000000..0355d9a --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Invoke-VsConfigImport.ps1 @@ -0,0 +1,76 @@ +Function Invoke-VsConfigImport { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] + [string]$Config, + [Parameter(Mandatory=$true, Position=1)] + [string]$VsInstallPath + ) + + try { + $UserProfilePath = (Get-EnvironmentVariable USERPROFILE) + if (-not (Test-Path -Path $UserProfilePath)) { + Write-StatusMessage "User profile path not found: $UserProfilePath" -Verbosity Error + return $false + } + } catch { + Write-StatusMessage "Failed to get user profile path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + $configFile = Join-Path -Path $UserProfilePath -ChildPath ".vssconfig-devsetup" + if (Test-Path $configFile) { + Remove-Item $configFile -Force + } + } catch { + Write-StatusMessage "Failed to create config file path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + # Write the decoded configuration to the config file + Set-Content -Path $configFile -Value $Config -Encoding UTF8 -Force + } catch { + Write-StatusMessage "Failed to write configuration to file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "Visual Studio configuration saved to: $configFile" -ForegroundColor Green + + if (-not (Test-Path -Path $VsInstallPath)) { + Write-StatusMessage "Visual Studio installation path not found: $VsInstallPath" -Verbosity Error + return $false + } + + if (-not (Test-Path -Path $configFile)) { + Write-StatusMessage "Configuration file not found: $configFile" -Verbosity Error + return $false + } + + $SetupCommand = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" + if (-not (Test-Path -Path $SetupCommand)) { + Write-StatusMessage "Visual Studio setup command not found: $SetupCommand" -Verbosity Error + return $false + } + + try { + # Run the Visual Studio installer with the config file (suppress output) + Write-StatusMessage "Running Visual Studio installer..." -Verbosity Debug + $result = Invoke-Command -ScriptBlock { & $SetupCommand modify --installPath $VsInstallPath --config "$configFile" --passive --allowUnsignedExtensions } + Write-StatusMessage "Result: $result" -Verbosity Debug + if ($LASTEXITCODE -ne 0) { + Write-StatusMessage "Visual Studio configuration import failed with exit code $LASTEXITCODE." -Verbosity Error + return $false + } + return $true + } catch { + Write-StatusMessage "Failed to process Visual Studio configuration: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudio/Wait-ForVisualStudioConfigFile.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Wait-ForVisualStudioConfigFile.Tests.ps1 new file mode 100644 index 0000000..92f2b19 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Wait-ForVisualStudioConfigFile.Tests.ps1 @@ -0,0 +1,206 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Wait-ForVisualStudioConfigFile.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + Mock Write-StatusMessage { } + Mock Start-Sleep { } +} + +Describe "Wait-ForVisualStudioConfigFile" { + + Context "When config file exists and has content immediately" { + BeforeEach { + Mock Test-Path { return $true } + Mock Get-Item { return @{ Length = 10 } } + } + It "Should return true without polling" { + $configFile = "$TestDrive\config.txt" + $result = Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 10 + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "[OK]" -and $ForegroundColor -eq "Green" } + Assert-MockCalled Start-Sleep -Exactly 0 -Scope It + } + } + + Context "When config file exists but is empty initially, then gets content" { + BeforeEach { + $script:testPathCallCount = 0 + $script:getItemCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $true + } + default { + return $true + } + } + } + Mock Get-Item { + switch ($script:getItemCallCount) { + 0 { + $script:getItemCallCount++ + return @{ Length = 0 } + } + default { + return @{ Length = 10 } + } + } + } + } + It "Should return true after polling" { + $configFile = "$TestDrive\config.txt" + New-Item -ItemType File -Path $configFile + $result = Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 10 -PollIntervalSeconds 1 + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "[OK]" -and $ForegroundColor -eq "Green" } + Assert-MockCalled Start-Sleep -Exactly 1 -Scope It + } + } + + Context "When config file does not exist initially, then is created with content" { + BeforeEach { + $script:testPathCallCount = 0 + $script:getItemCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $false + } + 1 { + $script:testPathCallCount++ + return $true + } + default { + return $true + } + } + } + Mock Get-Item { + switch ($script:getItemCallCount) { + 0 { + $script:getItemCallCount++ + return @{ Length = 10 } + } + default { + return @{ Length = 10 } + } + } + } + } + It "Should return true after polling" { + $configFile = "$TestDrive\config.txt" + $result = Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 10 -PollIntervalSeconds 1 + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "[OK]" -and $ForegroundColor -eq "Green" } + Assert-MockCalled Start-Sleep -Exactly 1 -Scope It + } + } + + Context "When config file does not exist and timeout is reached" { + It "Should return false and write timeout messages" { + Mock Test-Path { return $false } + $configFile = "$TestDrive\config.txt" + $result = Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 4 -PollIntervalSeconds 1 + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "[FAILED]" -and $ForegroundColor -eq "Red" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "The operation may still be running in the background. Check the installation manually." -and $Verbosity -eq "Warning" } + Assert-MockCalled Start-Sleep -Exactly 4 -Scope It # 4 / 1 = 4 polls + } + } + + Context "When config file exists but remains empty until timeout" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $true + } + default { + return $true + } + } + } + Mock Get-Item { return @{ Length = 0 } } + } + It "Should return false and write timeout messages" { + $configFile = "$TestDrive\config.txt" + New-Item -ItemType File -Path $configFile + $result = Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 4 -PollIntervalSeconds 1 + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "[FAILED]" -and $ForegroundColor -eq "Red" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "The operation may still be running in the background. Check the installation manually." -and $Verbosity -eq "Warning" } + Assert-MockCalled Start-Sleep -Exactly 4 -Scope It + } + } + + Context "When Test-Path throws an exception" { + It "Should return false and write timeout messages" { + Mock Test-Path { throw "Path access error" } + $configFile = "$TestDrive\config.txt" + { Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 4 -PollIntervalSeconds 1 } | Should -Throw "Path access error" + } + } + + Context "When Get-Item throws an exception" { + BeforeEach { + $script:testPathCallCount = 0 + Mock Test-Path { + switch ($script:testPathCallCount) { + 0 { + $script:testPathCallCount++ + return $true + } + default { + return $true + } + } + } + Mock Get-Item { throw "Item access error" } + } + It "Should return false and write timeout messages" { + $configFile = "$TestDrive\config.txt" + New-Item -ItemType File -Path $configFile + { Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 4 -PollIntervalSeconds 1 } | Should -Throw "Item access error" + } + } + + Context "When config file path is empty" { + It "Should throw due to parameter validation" { + { Wait-ForVisualStudioConfigFile -ConfigFilePath "" } | Should -Throw + } + } + + Context "When config file path is null" { + It "Should throw due to parameter validation" { + { Wait-ForVisualStudioConfigFile -ConfigFilePath $null } | Should -Throw + } + } + + Context "When timeout is zero" { + It "Should return false immediately if file not ready" { + Mock Test-Path { return $false } + $configFile = "$TestDrive\config.txt" + $result = Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 0 + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "[FAILED]" -and $ForegroundColor -eq "Red" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "The operation may still be running in the background. Check the installation manually." -and $Verbosity -eq "Warning" } + Assert-MockCalled Start-Sleep -Exactly 0 -Scope It + } + } + + Context "When poll interval is larger than timeout" { + It "Should return false after one poll" { + Mock Test-Path { return $false } + $configFile = "$TestDrive\config.txt" + $result = Wait-ForVisualStudioConfigFile -ConfigFilePath $configFile -TimeoutSeconds 1 -PollIntervalSeconds 5 + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "[FAILED]" -and $ForegroundColor -eq "Red" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "The operation may still be running in the background. Check the installation manually." -and $Verbosity -eq "Warning" } + Assert-MockCalled Start-Sleep -Exactly 1 -Scope It + } + } +} diff --git a/DevSetup/Private/3rdParty/VisualStudio/Wait-ForVisualStudioConfigFile.ps1 b/DevSetup/Private/3rdParty/VisualStudio/Wait-ForVisualStudioConfigFile.ps1 new file mode 100644 index 0000000..eab26b9 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudio/Wait-ForVisualStudioConfigFile.ps1 @@ -0,0 +1,28 @@ +Function Wait-ForVisualStudioConfigFile { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory=$true, Position=0)] + [ValidateNotNullOrEmpty()] + [string]$ConfigFilePath, + [int]$TimeoutSeconds = 60, + [int]$PollIntervalSeconds = 2 + ) + + $ElapsedSeconds = 0 + + Write-StatusMessage "- Waiting for Visual Studio config file to be created" -ForegroundColor Gray -NoNewline -Indent 4 -Width 112 + + while ($ElapsedSeconds -lt $TimeoutSeconds) { + if ((Test-Path -Path $ConfigFilePath) -and (Get-Item $ConfigFilePath).Length -gt 0) { + Write-StatusMessage "[OK]" -ForegroundColor Green + return $true + } + Start-Sleep -Seconds $PollIntervalSeconds + $ElapsedSeconds += $PollIntervalSeconds + } + + Write-StatusMessage "[FAILED]" -ForegroundColor Red + Write-StatusMessage "The operation may still be running in the background. Check the installation manually." -Verbosity Warning + return $false +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Add-VsCodeToPackageManager.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Add-VsCodeToPackageManager.Tests.ps1 new file mode 100644 index 0000000..5bec17f --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Add-VsCodeToPackageManager.Tests.ps1 @@ -0,0 +1,142 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Add-VsCodeToPackageManager.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Update-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } # Default to Windows + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } # Default YAML + Mock Write-StatusMessage { } + Mock Update-DevSetupEnvFile { } +} + +Describe "Add-VsCodeToPackageManager" { + + Context "When on Windows and vscode not in packages" { + It "Should add vscode and save config" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "- Configuration updated successfully" -and $Verbosity -eq "Debug" } + } + } + + Context "When on Windows and vscode already in packages as string" { + It "Should return true without adding" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @("vscode") } } } } } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 0 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Visual Studio Code is already listed as a chocolatey package." -and $Verbosity -eq "Debug" } + } + } + + Context "When on Windows and vscode already in packages as hashtable" { + It "Should return true without adding" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @(@{ name = "vscode"; version = "1.0" }) } } } } } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 0 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Visual Studio Code is already listed as a chocolatey package." -and $Verbosity -eq "Debug" } + } + } + + Context "When on Windows and YAML structure is missing" { + It "Should create structure and add vscode" { + Mock Read-DevSetupEnvFile { + @{ + devsetup = @{ + configuration = @{} + dependencies = @{} + commands = @() + } + } + } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + } + } + + Context "When on Windows and saving fails" { + It "Should return false and write error" { + Mock Update-DevSetupEnvFile { throw "Save failed" } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to save updated configuration:" -and $Verbosity -eq "Error" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Save failed" -and $Verbosity -eq "Error" } + } + } + + Context "When on Windows and DryRun is true" { + It "Should not save config" { + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" -DryRun:$true + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + } + } + + Context "When on Linux" { + It "Should return false and write message" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $true } + if ($MacOS) { return $false } + } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Find-VsCode is only supported on Windows at this time" -and $Verbosity -eq "Debug" } + } + } + + Context "When on macOS" { + It "Should return false and write message" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $false } + if ($MacOS) { return $true } + } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Find-VsCode is only supported on Windows at this time" -and $Verbosity -eq "Debug" } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + } + + It "Should work on Linux" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $true } + if ($MacOS) { return $false } + } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + } + + It "Should work on macOS" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $false } + if ($MacOS) { return $true } + } + $result = Add-VsCodeToPackageManager -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Add-VsCodeToPackageManager.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Add-VsCodeToPackageManager.ps1 new file mode 100644 index 0000000..dadba78 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Add-VsCodeToPackageManager.ps1 @@ -0,0 +1,49 @@ +Function Add-VsCodeToPackageManager { + [CmdletBinding()] + [OutputType([bool])] + Param( + [Parameter(Mandatory=$true, Position=0)] + [string]$Config, + [switch]$DryRun + ) + + if ((Test-OperatingSystem -Windows)) { + $YamlData = Read-DevSetupEnvFile -Config $Config + + # Ensure chocolatey-specific sections exist + if (-not $YamlData.devsetup.dependencies.chocolatey) { $YamlData.devsetup.dependencies.chocolatey = @{} } + if (-not $YamlData.devsetup.dependencies.chocolatey.packages) { $YamlData.devsetup.dependencies.chocolatey.packages = @() } + # Check if vscode is already in chocolatey packages + $existingVscodePackage = $YamlData.devsetup.dependencies.chocolatey.packages | Where-Object { + ($_ -is [string] -and $_ -eq "vscode") -or + ($_.name -eq "vscode") + } + if ($existingVscodePackage) { + Write-StatusMessage "Visual Studio Code is already listed as a chocolatey package." -Verbosity Debug + return $true + } else { + # Add vscode to chocolatey packages + $YamlData.devsetup.dependencies.chocolatey.packages += @{ + name = "vscode" + version = $null + } + + try { + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun + Write-StatusMessage "- Configuration updated successfully" -Verbosity Debug + return $true + } + catch { + Write-StatusMessage "Failed to save updated configuration: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + } + } elseif (Test-OperatingSystem -Linux) { + Write-StatusMessage "Find-VsCode is only supported on Windows at this time" -Verbosity Debug + return $false + } elseif (Test-OperatingSystem -MacOS) { + Write-StatusMessage "Find-VsCode is only supported on Windows at this time" -Verbosity Debug + return $false + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/ConvertFrom-VisualStudioCodeInstall.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/ConvertFrom-VisualStudioCodeInstall.Tests.ps1 new file mode 100644 index 0000000..983363c --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/ConvertFrom-VisualStudioCodeInstall.Tests.ps1 @@ -0,0 +1,165 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "ConvertFrom-VisualStudioCodeInstall.ps1") + . (Join-Path $PSScriptRoot "Find-VsCode.ps1") + . (Join-Path $PSScriptRoot "Add-VsCodeToPackageManager.ps1") + . (Join-Path $PSScriptRoot "Invoke-VsCodeExtensionsExport.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Update-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } # Default to Windows + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @() } } } + Mock Find-VsCode { "$TestDrive\Code\bin\code.cmd" } + Mock Add-VsCodeToPackageManager { $true } + Mock Invoke-VsCodeExtensionsExport { "mocked extensions json" } + Mock Write-StatusMessage { } + Mock Update-DevSetupEnvFile { } +} + +Describe "ConvertFrom-VisualStudioCodeInstall" { + + Context "When VS Code is found and all operations succeed" { + It "Should update config and return true" { + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Add-VsCodeToPackageManager -Exactly 1 -Scope It + Assert-MockCalled Invoke-VsCodeExtensionsExport -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Visual Studio Code installation conversion completed!" -and $ForegroundColor -eq "Green" } + } + } + + Context "When VS Code is not found" { + It "Should skip and return true" { + Mock Find-VsCode { $null } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Add-VsCodeToPackageManager -Exactly 0 -Scope It + Assert-MockCalled Invoke-VsCodeExtensionsExport -Exactly 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "- Visual Studio Code not detected, skipping extension export" -and $ForegroundColor -eq "Yellow" } + } + } + + Context "When Add-VsCodeToPackageManager fails" { + It "Should return false" { + Mock Add-VsCodeToPackageManager { $false } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + Assert-MockCalled Invoke-VsCodeExtensionsExport -Exactly 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" } + } + } + + Context "When Invoke-VsCodeExtensionsExport fails" { + It "Should return true" { + Mock Invoke-VsCodeExtensionsExport { $null } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + Assert-MockCalled Update-DevSetupEnvFile -Exactly 0 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" } + $result | Should -Be $true + } + } + + Context "When saving config fails" { + It "Should return false and write error" { + Mock Update-DevSetupEnvFile { throw "Save failed" } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to save updated devsetup environment:" -and $Verbosity -eq "Error" } + } + } + + Context "When DryRun is true" { + It "Should not save config" { + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" -DryRun:$true + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + } + } + + Context "When existing command is present" { + It "Should update the existing command" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ packageName = "invoke.vs.code.extensions.import"; command = "old"; params = @{} }) } } } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "- Updating Visual Studio Code import command..." -and $ForegroundColor -eq "Gray" } + } + } + + Context "When no existing command" { + It "Should add new command" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @() } } } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "- Adding Visual Studio Code import command..." -and $ForegroundColor -eq "Gray" } + } + } + + Context "When YAML structure is missing" { + It "Should create structure" { + Mock Read-DevSetupEnvFile { + @{ + devsetup = @{ + configuration = @{} + dependencies = @{} + commands = @() + } + } + } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $EnvFilePath -eq "$TestDrive\config.devsetup" } + } + } + + Context "When exception occurs in try block" { + It "Should return false and write error" { + Mock Read-DevSetupEnvFile { throw "Read failed" } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error detecting Visual Studio Code installation:" -and $Verbosity -eq "Error" } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + } + + It "Should work on Linux" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $true } + if ($MacOS) { return $false } + } + Mock Find-VsCode { $null } # VS Code not found on Linux + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + } + + It "Should work on macOS" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $false } + if ($MacOS) { return $true } + } + Mock Find-VsCode { $null } # VS Code not found on macOS + $result = ConvertFrom-VisualStudioCodeInstall -Config "$TestDrive\config.devsetup" + $result | Should -Be $true + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/ConvertFrom-VisualStudioCodeInstall.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/ConvertFrom-VisualStudioCodeInstall.ps1 index 9f01805..1690a56 100644 --- a/DevSetup/Private/3rdParty/VisualStudioCode/ConvertFrom-VisualStudioCodeInstall.ps1 +++ b/DevSetup/Private/3rdParty/VisualStudioCode/ConvertFrom-VisualStudioCodeInstall.ps1 @@ -1,186 +1,87 @@ Function ConvertFrom-VisualStudioCodeInstall { + [CmdletBinding()] + [OutputType([bool])] Param ( - [string]$Config + [string]$Config, + [switch]$DryRun ) try { - Write-Host "- Detecting Visual Studio Code installation..." -ForegroundColor Gray + Write-StatusMessage "- Detecting Visual Studio Code installation..." -ForegroundColor Gray # Read existing configuration - $YamlData = Read-ConfigurationFile -Config $Config + $YamlData = Read-DevSetupEnvFile -Config $Config - # Ensure chocolateyPackages section exists - if (-not $YamlData.devsetup) { $YamlData.devsetup = @{} } - if (-not $YamlData.devsetup.dependencies) { $YamlData.devsetup.dependencies = @{} } - if (-not $YamlData.devsetup.dependencies.chocolatey) { $YamlData.devsetup.dependencies.chocolatey = @{} } - if (-not $YamlData.devsetup.dependencies.chocolatey.packages) { $YamlData.devsetup.dependencies.chocolatey.packages = @() } - - # Check if vscode is already in chocolatey packages - $existingVscodePackage = $YamlData.devsetup.dependencies.chocolatey.packages | Where-Object { - ($_ -is [string] -and $_ -eq "vscode") -or - ($_ -is [hashtable] -and $_.name -eq "vscode") - } - - if ($existingVscodePackage) { - Write-Host " - Visual Studio Code already configured in chocolatey packages" -ForegroundColor Green - - # Export VS Code configuration - Write-Host " - Exporting VS Code configuration..." -ForegroundColor Gray - $encodedConfig = Export-VsCodeConfig - - if ($encodedConfig) { - # Ensure commands section exists - if (-not $YamlData.devsetup.commands) { $YamlData.devsetup.commands = @() } - - # Check if vscode.importConfig command already exists + # Ensure commands section exists + if (-not $YamlData.devsetup.commands) { $YamlData.devsetup.commands = @() } + + $vsCode = Find-VsCode + + if ($vsCode) { + Write-StatusMessage "- Adding Visual Studio Code to package manager" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + $packageAddStatus = Add-VsCodeToPackageManager -Config $Config -DryRun:$DryRun + if ($packageAddStatus) { + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + return $false + } + + Write-StatusMessage "- Exporting Visual Studio Code extensions..." -Indent 2 -ForegroundColor Gray -Width 112 -NoNewline + $extensions = Invoke-VsCodeExtensionsExport + + if ($extensions) { + Write-StatusMessage "[OK]" -ForegroundColor Green + # Check if import.vscode.extensions command already exists $existingCommand = $YamlData.devsetup.commands | Where-Object { - ($_ -is [hashtable] -and $_.packageName -eq "vscode.importConfig") + ($_.packageName -eq "invoke.vs.code.extensions.import" -or $_.packageName -eq "vscode.importConfig") } if ($existingCommand) { + $commandIndex = $YamlData.devsetup.commands.IndexOf($existingCommand) # Update existing command with new encoded config - $existingCommand.command = "Import-VsCodeConfig -EncodedConfig $encodedConfig" - Write-Host " - VS Code import command updated in configuration" -ForegroundColor Green - } - else { - # Add new Import-VsCodeConfig command + $YamlData.devsetup.commands[$commandIndex] = @{ + packageName = "invoke.vs.code.extensions.import" + command = "Invoke-VsCodeExtensionsImport" + params = @{ + extensions = $extensions + } + } + Write-StatusMessage "- Updating Visual Studio Code import command..." -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + } else { + # Add new Invoke-VsCodeExtensionsImport command $YamlData.devsetup.commands += @{ - command = "Import-VsCodeConfig -EncodedConfig '$encodedConfig'" - packageName = "vscode.importConfig" + command = "Invoke-VsCodeExtensionsImport" + packageName = "invoke.vs.code.extensions.import" + params = @{ + extensions = $extensions + } } - Write-Host " - VS Code import command added to configuration" -ForegroundColor Green + Write-StatusMessage "- Adding Visual Studio Code import command..." -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline } # Save updated configuration try { - $yamlOutput = $YamlData | ConvertTo-Yaml - $yamlOutput | Out-File -FilePath $Config -Encoding UTF8 - Write-Host " - Configuration updated successfully" -ForegroundColor Green - } - catch { - Write-Error "Failed to save updated configuration: $_" + Write-StatusMessage "[OK]" -ForegroundColor Green + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun + } catch { + Write-StatusMessage "Failed to save updated devsetup environment: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } - } - else { - Write-Host " - No VS Code configuration to export" -ForegroundColor Yellow + } else { + Write-StatusMessage "[FAILED]" -ForegroundColor Red } + Write-StatusMessage "Visual Studio Code installation conversion completed!" -ForegroundColor Green + return $true + } else { + Write-StatusMessage "- Visual Studio Code not detected, skipping extension export" -ForegroundColor Yellow -Indent 2 return $true } - - # Check for manual installation using multiple methods - $vscodeInstalled = $false - $detectionMethod = "" - - # Method 1: Check if 'code --version' works - try { - $codeVersion = & code --version 2>$null - if ($LASTEXITCODE -eq 0 -and $codeVersion) { - $vscodeInstalled = $true - $detectionMethod = "command line (code --version)" - Write-Host " - Found VS Code via command line: $($codeVersion[0])" -ForegroundColor Gray - } - } - catch { - # Command not found, continue with other methods - } - - # Method 2: Check registry - if (-not $vscodeInstalled) { - try { - $regPath = "HKLM:\SOFTWARE\Classes\Applications\Code.exe\shell\open\command" - $regValue = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue - if ($regValue) { - $vscodeInstalled = $true - $detectionMethod = "registry" - Write-Host " - Found VS Code via registry" -ForegroundColor Gray - } - } - catch { - # Registry check failed, continue - } - } - - # Method 3: Filesystem checks - if (-not $vscodeInstalled) { - $userPath = "$env:LocalAppData\Programs\Microsoft VS Code\bin\code.cmd" - $systemPath = "$env:ProgramFiles\Microsoft VS Code\bin\code.cmd" - - if (Test-Path $userPath) { - $vscodeInstalled = $true - $detectionMethod = "user installation path" - Write-Host " - Found VS Code at: $userPath" -ForegroundColor Gray - } - elseif (Test-Path $systemPath) { - $vscodeInstalled = $true - $detectionMethod = "system installation path" - Write-Host " - Found VS Code at: $systemPath" -ForegroundColor Gray - } - } - - # Method 4: Get-Package check - if (-not $vscodeInstalled) { - try { - $package = Get-Package -Name "*vscode*" -ErrorAction SilentlyContinue - if ($package) { - $vscodeInstalled = $true - $detectionMethod = "package manager" - Write-Host " - Found VS Code via Get-Package: $($package.Name)" -ForegroundColor Gray - } - } - catch { - # Get-Package failed, continue - } - } - - if ($vscodeInstalled) { - Write-Host " - Visual Studio Code detected ($detectionMethod), adding to chocolatey packages" -ForegroundColor Green - - # Add vscode to chocolatey packages - $YamlData.devsetup.dependencies.chocolatey.packages += @{ - name = "vscode" - version = $null - } - - # Export VS Code configuration - Write-Host " - Exporting VS Code configuration..." -ForegroundColor Gray - $encodedConfig = Export-VsCodeConfig - - if ($encodedConfig) { - # Ensure commands section exists - if (-not $YamlData.devsetup.commands) { $YamlData.devsetup.commands = @() } - - # Add Import-VsCodeConfig command - $YamlData.devsetup.commands += @{ - command = "Import-VsCodeConfig -EncodedConfig '$encodedConfig'" - packageName = "vscode.importConfig" - } - Write-Host " - VS Code import command added to configuration" -ForegroundColor Green - } - else { - Write-Host " - No VS Code configuration to export" -ForegroundColor Yellow - } - - # Save updated configuration - try { - $yamlOutput = $YamlData | ConvertTo-Yaml - $yamlOutput | Out-File -FilePath $Config -Encoding UTF8 - Write-Host " - Configuration updated successfully" -ForegroundColor Green - } - catch { - Write-Error "Failed to save updated configuration: $_" - return $false - } - } - else { - Write-Host " - Visual Studio Code not detected on this system" -ForegroundColor Yellow - } - - return $true - } - catch { - Write-Error "Error detecting Visual Studio Code installation: $_" + } catch { + Write-StatusMessage "Error detecting Visual Studio Code installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } } \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Export-VsCodeConfig.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Export-VsCodeConfig.ps1 deleted file mode 100644 index 8fec9e6..0000000 --- a/DevSetup/Private/3rdParty/VisualStudioCode/Export-VsCodeConfig.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -Function Export-VsCodeConfig { - Param( - - ) - - try { - Write-Host " - Exporting VS Code configuration..." -ForegroundColor Gray - - # Check if 'code' command is available - $codeCommand = Get-Command code -ErrorAction SilentlyContinue - if (-not $codeCommand) { - Write-Warning "VS Code 'code' command not found in PATH. Cannot export extensions." - return $null - } - - Write-Host " - VS Code command found, listing extensions..." -ForegroundColor Gray - - # Get list of installed extensions - try { - $command = { - & code --list-extensions --show-versions 2>$null - } - $extensionsOutput = Invoke-Command -ScriptBlock $command - if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to get VS Code extensions list" - return $null - } - - # Convert output to array (filter out empty lines) - $extensionsArray = $extensionsOutput | Where-Object { $_ -and $_.Trim() -ne "" } - - if (-not $extensionsArray -or $extensionsArray.Count -eq 0) { - Write-Host " - No VS Code extensions found" -ForegroundColor Yellow - return $null - } - - Write-Host " - Found $($extensionsArray.Count) VS Code extensions" -ForegroundColor Gray - - # Convert array to JSON - $jsonData = $extensionsArray | ConvertTo-Json - - # Convert JSON to Base64 - $base64Config = ConvertTo-Base64 -InputString $jsonData - - if (-not $base64Config) { - Write-Error "Failed to encode VS Code extensions to Base64" - return $null - } - - Write-Host " - VS Code extensions exported and encoded successfully" -ForegroundColor Gray - return $base64Config - } - catch { - Write-Error "Error getting VS Code extensions: $_" - return $null - } - } - catch { - Write-Error "Error exporting VS Code configuration: $_" - return $null - } -} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Find-VsCode.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Find-VsCode.Tests.ps1 new file mode 100644 index 0000000..50d1ba0 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Find-VsCode.Tests.ps1 @@ -0,0 +1,115 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Find-VsCode.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + Mock Test-OperatingSystem { return $true } # Default to Windows + Mock Get-Command { throw "Command not found" } # Default to not found + Mock Get-EnvironmentVariable { + if ($Name -eq "LocalAppData") { + return "$TestDrive\LocalAppData" + } elseif ($Name -eq "ProgramFiles") { + return "$TestDrive\ProgramFiles" + } + } + Mock Test-Path { $false } # Default to not exist + Mock Write-StatusMessage { } +} + +Describe "Find-VsCode" { + + Context "When not on Windows" { + It "Should return null and write warning" { + Mock Test-OperatingSystem { return $false } + $result = Find-VsCode + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Find-VsCode is only supported on Windows at this time" -and $Verbosity -eq "Debug" } + } + } + + Context "When on Windows and Get-Command succeeds" { + It "Should return the path from Get-Command" { + Mock Get-Command { [PSCustomObject]@{ Path = "$TestDrive\Code\bin\code.cmd" } } + $result = Find-VsCode + $result | Should -Be "$TestDrive\Code\bin\code.cmd" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Found Visual Studio Code at" -and $Verbosity -eq "Debug" } + Assert-MockCalled Get-EnvironmentVariable -Exactly 0 -Scope It + Assert-MockCalled Test-Path -Exactly 0 -Scope It + } + } + + Context "When on Windows and Get-Command fails with exception" { + It "Should write debug message and continue to check paths" { + Mock Get-Command { throw "Command not found" } + Mock Test-Path { $false } + $result = Find-VsCode + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Get-Command code failed:" -and $Verbosity -eq "Debug" } + } + } + + Context "When on Windows and Get-Command fails, but user path exists" { + It "Should return the user path" { + Mock Get-Command { throw "Command not found" } + Mock Test-Path { + if ($Path -eq "$TestDrive\LocalAppData\Programs\Microsoft VS Code\bin\code.cmd") { + return $true + } else { + return $false + } + } + $result = Find-VsCode + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Found Visual Studio Code at" -and $Verbosity -eq "Debug" } + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq "$TestDrive\LocalAppData\Programs\Microsoft VS Code\bin\code.cmd" } + $result | Should -Be "$TestDrive\LocalAppData\Programs\Microsoft VS Code\bin\code.cmd" + } + } + + Context "When on Windows and Get-Command fails, user path doesn't exist, but system path exists" { + It "Should return the system path" { + Mock Get-Command { throw "Command not found" } + Mock Test-Path { + if ($Path -eq "$TestDrive\ProgramFiles\Microsoft VS Code\bin\code.cmd") { + return $true + } else { + return $false + } + } + $result = Find-VsCode + $result | Should -Be "$TestDrive\ProgramFiles\Microsoft VS Code\bin\code.cmd" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Found Visual Studio Code at" -and $Verbosity -eq "Debug" } + Assert-MockCalled Test-Path -Exactly 2 -Scope It # Once for user, once for system + } + } + + Context "When on Windows and none of the paths are found" { + It "Should return null" { + Mock Get-Command { throw "Command not found" } + Mock Test-Path { $false } + $result = Find-VsCode + $result | Should -Be $null + Assert-MockCalled Test-Path -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 0 -Scope It -ParameterFilter { $Verbosity -eq "Debug" -and $Message -match "Found Visual Studio Code" } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Get-Command { [PSCustomObject]@{ Path = "$TestDrive\Code\bin\code.cmd" } } + $result = Find-VsCode + $result | Should -Be "$TestDrive\Code\bin\code.cmd" + } + + It "Should work on Linux" { + Mock Test-OperatingSystem { Param($Windows, $Linux, $MacOS) { return $false } } + $result = Find-VsCode + $result | Should -Be $null + } + + It "Should work on macOS" { + Mock Test-OperatingSystem { Param($Windows, $Linux, $MacOS) { return $false } } + $result = Find-VsCode + $result | Should -Be $null + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Find-VsCode.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Find-VsCode.ps1 new file mode 100644 index 0000000..03dbd24 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Find-VsCode.ps1 @@ -0,0 +1,34 @@ +Function Find-VsCode { + [CmdletBinding()] + [OutputType([string])] + Param () + + if (-not (Test-OperatingSystem -Windows)) { + Write-StatusMessage "Find-VsCode is only supported on Windows at this time" -Verbosity Debug + return $null + } else { + try { + $codeCommand = (Get-Command code -ErrorAction SilentlyContinue).Path + if ($codeCommand) { + Write-StatusMessage "Found Visual Studio Code at $codeCommand" -Verbosity Debug + return $codeCommand + } + } catch { + Write-StatusMessage "Get-Command code failed: $_" -Verbosity Debug + Write-StatusMessage $_.ScriptStackTrace -Verbosity Debug + } + + $userPath = [string]::Format("{0}\Programs\Microsoft VS Code\bin\code.cmd", (Get-EnvironmentVariable -Name "LocalAppData")) + $systemPath = [string]::Format("{0}\Microsoft VS Code\bin\code.cmd", (Get-EnvironmentVariable -Name "ProgramFiles")) + + if (Test-Path $userPath) { + Write-StatusMessage "Found Visual Studio Code at $userPath" -Verbosity Debug + return $userPath + } + + if (Test-Path $systemPath) { + Write-StatusMessage "Found Visual Studio Code at $systemPath" -Verbosity Debug + return $systemPath + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Import-VsCodeConfig.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Import-VsCodeConfig.ps1 deleted file mode 100644 index 9208043..0000000 --- a/DevSetup/Private/3rdParty/VisualStudioCode/Import-VsCodeConfig.ps1 +++ /dev/null @@ -1,123 +0,0 @@ -Function Import-VsCodeConfig { - Param( - [string]$EncodedConfig - ) - - try { - Write-Host "- Importing VS Code configuration..." -ForegroundColor Gray - - if (-not $EncodedConfig) { - Write-Warning "No encoded configuration provided" - return $false - } - - # Check if 'code' command is available - $codeCommand = Get-Command code -ErrorAction SilentlyContinue - $codePath = $null - - if ($codeCommand) { - $codePath = "code" - Write-Host " - VS Code command found in PATH" -ForegroundColor Gray - } - else { - # Manual path checks when code command is not in PATH - $userPath = "$env:LocalAppData\Programs\Microsoft VS Code\bin\code.cmd" - $systemPath = "$env:ProgramFiles\Microsoft VS Code\bin\code.cmd" - - if (Test-Path $userPath) { - $codePath = $userPath - Write-Host " - VS Code found at user path: $userPath" -ForegroundColor Gray - } - elseif (Test-Path $systemPath) { - $codePath = $systemPath - Write-Host " - VS Code found at system path: $systemPath" -ForegroundColor Gray - } - } - - if (-not $codePath) { - Write-Warning "VS Code executable not found. Cannot install extensions." - return $false - } - - Write-Host " - VS Code command found, decoding configuration..." -ForegroundColor Gray - - # Decode the base64 configuration - $decodedJson = ConvertFrom-Base64 -EncodedString $EncodedConfig - if (-not $decodedJson) { - Write-Error "Failed to decode base64 configuration" - return $false - } - - Write-Host " - Configuration decoded, parsing JSON..." -ForegroundColor Gray - - # Convert from JSON - try { - $extensions = $decodedJson | ConvertFrom-Json - } - catch { - Write-Error "Failed to parse JSON from decoded configuration: $_" - return $false - } - - # Handle both array and single string cases - if ($extensions -is [string]) { - # Single extension - $extensionList = @($extensions) - } - elseif ($extensions -is [array]) { - # Array of extensions - $extensionList = $extensions - } - else { - Write-Error "Unexpected extension data type: $($extensions.GetType())" - return $false - } - - if ($extensionList.Count -eq 0) { - Write-Host " - No extensions to install" -ForegroundColor Yellow - return $true - } - - Write-Host " - Installing $($extensionList.Count) VS Code extensions..." -ForegroundColor Gray - - $successCount = 0 - $failureCount = 0 - - # Install each extension - foreach ($extension in $extensionList) { - if (-not $extension -or $extension.Trim() -eq "") { - continue - } - - Write-Host " - Installing extension: $extension" -ForegroundColor Gray - - try { - $command = { - & $codePath --install-extension $extension --force 2>&1 - } - $result = Invoke-Command -ScriptBlock $command - if ($LASTEXITCODE -eq 0) { - Write-Host " - Successfully installed: $extension" -ForegroundColor Green - $successCount++ - } - else { - Write-Warning " - Failed to install: $extension - $result" - $failureCount++ - } - } - catch { - Write-Warning " - Error installing: $extension - $_" - $failureCount++ - } - } - - # Summary - Write-Host " - Extension installation complete: $successCount successful, $failureCount failed" -ForegroundColor Gray - - return $true - } - catch { - Write-Error "Error importing VS Code configuration: $_" - return $false - } -} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsExport.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsExport.Tests.ps1 new file mode 100644 index 0000000..0dc43d8 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsExport.Tests.ps1 @@ -0,0 +1,114 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-VsCodeExtensionsExport.ps1") + . (Join-Path $PSScriptRoot "Find-VsCode.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + Mock Find-VsCode { "$TestDrive\Code\bin\code.cmd" } # Default to found + Mock Invoke-Command { "extension1@1.0.0", "extension2@2.0.0" } # Default to success with extensions + Mock Write-StatusMessage { } + Mock ConvertTo-Json { "mocked json output" } # Default to success + $script:LASTEXITCODE = 0 # Default to success +} + +Describe "Invoke-VsCodeExtensionsExport" { + + Context "When Find-VsCode returns null" { + It "Should return null and write warning" { + Mock Find-VsCode { $null } + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Visual Studio Code 'code' command not found in PATH. Cannot export extensions." -and $Verbosity -eq "Debug" } + } + } + + Context "When Invoke-Command succeeds with extensions" { + It "Should return JSON data" { + Mock Invoke-Command { "extension1@1.0.0", "extension2@2.0.0" } + $script:LASTEXITCODE = 0 + $result = Invoke-VsCodeExtensionsExport + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "- Found 2 Visual Studio Code extensions" -and $Verbosity -eq "Debug" } + Assert-MockCalled ConvertTo-Json -Exactly 2 -Scope It + $result | Should -Be @("mocked json output", "mocked json output") + } + } + + Context "When Invoke-Command succeeds but no extensions" { + It "Should return null" { + Mock Invoke-Command { @() } + $script:LASTEXITCODE = 0 + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "- No Visual Studio Code extensions found" -and $Verbosity -eq "Debug" } + } + } + + Context "When Invoke-Command fails with non-zero exit code" { + It "Should return null and write warning" { + Mock Invoke-Command { "some output" } + $script:LASTEXITCODE = 1 + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get Visual Studio Code extensions list" -and $Verbosity -eq "Debug" } + } + } + + Context "When Invoke-Command throws exception" { + It "Should return null and write error" { + Mock Invoke-Command { throw "Command failed" } + $script:LASTEXITCODE = 0 + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error getting Visual Studio Code extensions:" -and $Verbosity -eq "Error" } + } + } + + Context "When ConvertTo-Json fails" { + It "Should return null and write error" { + Mock Invoke-Command { "extension1@1.0.0" } + $script:LASTEXITCODE = 0 + Mock ConvertTo-Json { throw "JSON conversion failed" } + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error getting Visual Studio Code extensions:" -and $Verbosity -eq "Error" } + } + } + + Context "When outer try-catch catches exception" { + It "Should return null and write error" { + Mock Find-VsCode { throw "Unexpected error" } + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error exporting Visual Studio Code configuration:" -and $Verbosity -eq "Error" } + } + } + + Context "When extensions output has empty lines" { + It "Should filter out empty lines" { + Mock Invoke-Command { "extension1@1.0.0", "", "extension2@2.0.0", " " } + $script:LASTEXITCODE = 0 + $result = Invoke-VsCodeExtensionsExport + Assert-MockCalled ConvertTo-Json -Exactly 2 -Scope It + $result | Should -Be @("mocked json output", "mocked json output") + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Invoke-Command { "extension1@1.0.0" } + $script:LASTEXITCODE = 0 + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be "mocked json output" + } + + It "Should work on Linux" { + Mock Find-VsCode { $null } # VS Code not found on Linux + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + } + + It "Should work on macOS" { + Mock Find-VsCode { $null } # VS Code not found on macOS + $result = Invoke-VsCodeExtensionsExport + $result | Should -Be $null + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsExport.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsExport.ps1 new file mode 100644 index 0000000..77cafaf --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsExport.ps1 @@ -0,0 +1,45 @@ +Function Invoke-VsCodeExtensionsExport { + [CmdletBinding()] + [OutputType([string])] + Param() + + try { + # Check if 'code' command is available + $codeCommand = Find-VsCode + if (-not $codeCommand) { + Write-StatusMessage "Visual Studio Code 'code' command not found in PATH. Cannot export extensions." -Verbosity Debug + return $null + } + + # Get list of installed extensions + try { + $extensionsOutput = Invoke-Command -ScriptBlock { & $codeCommand --list-extensions --show-versions 2>$null } + if ($LASTEXITCODE -ne 0) { + Write-StatusMessage "Failed to get Visual Studio Code extensions list" -Verbosity Debug + return $null + } + + # Convert output to array (filter out empty lines) + $extensionsArray = $extensionsOutput | Where-Object { $_ -and $_.Trim() -ne "" } + + if (-not $extensionsArray -or $extensionsArray.Count -eq 0) { + Write-StatusMessage "- No Visual Studio Code extensions found" -Indent 2 -Verbosity Debug + return $null + } + + Write-StatusMessage "- Found $($extensionsArray.Count) Visual Studio Code extensions" -Indent 2 -Verbosity Debug + + # Convert array to JSON + $jsonData = $extensionsArray | ConvertTo-Json + return $jsonData + } catch { + Write-StatusMessage "Error getting Visual Studio Code extensions: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + } catch { + Write-StatusMessage "Error exporting Visual Studio Code configuration: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } +} \ No newline at end of file diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsImport.Tests.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsImport.Tests.ps1 new file mode 100644 index 0000000..543b737 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsImport.Tests.ps1 @@ -0,0 +1,161 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-VsCodeExtensionsImport.ps1") + . (Join-Path $PSScriptRoot "Find-VsCode.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + Mock Find-VsCode { "code" } + Mock Write-StatusMessage { } + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 0 + } +} + +Describe "Invoke-VsCodeExtensionsImport" { + + Context "When no extensions are provided" { + It "Should return false and write warning" { + $result = Invoke-VsCodeExtensionsImport -Extensions "" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "No extensions provided" -and $Verbosity -eq "Warning" } + } + } + + Context "When extensions is an empty array" { + It "Should return true and write message" { + $result = Invoke-VsCodeExtensionsImport -Extensions "[]" + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "No extensions found in provided configuration" -and $Verbosity -eq "Warning"} + } + } + + Context "When JSON parsing fails" { + It "Should return false and write error" { + $result = Invoke-VsCodeExtensionsImport -Extensions "invalid json" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to parse JSON" -and $Verbosity -eq "Error" } + } + } + + Context "When Find-VsCode fails" { + It "Should return false and write error" { + Mock Find-VsCode { $null } + $result = Invoke-VsCodeExtensionsImport -Extensions '["ms-vscode.powershell"]' + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Visual Studio Code executable not found" -and $Verbosity -eq "Error" } + } + } + + Context "When installing a single extension successfully" { + It "Should return true and write success" { + $result = Invoke-VsCodeExtensionsImport -Extensions '["ms-vscode.powershell"]' + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Installing extension: ms-vscode.powershell" -and $ForegroundColor -eq "Gray" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "[OK]" -and $ForegroundColor -eq "Green" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Extension installation complete: 1 successful" -and $ForegroundColor -eq "Gray" } + } + } + + Context "When installing a single extension fails" { + It "Should return true and write failure" { + Mock Invoke-Command { + param($ScriptBlock) + $script:LASTEXITCODE = 1 + } + $result = Invoke-VsCodeExtensionsImport -Extensions '["invalid.extension"]' + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Extension installation complete: 0 successful, 1 failed" -and $ForegroundColor -eq "Gray" } + } + } + + Context "When installing multiple extensions with mixed results" { + It "Should return true and write summary" { + $script:count = 0 + Mock Invoke-Command { + param($ScriptBlock) + $script:count++ + $script:LASTEXITCODE = $script:count % 2 + } + $result = Invoke-VsCodeExtensionsImport -Extensions '["ext1", "ext2"]' + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Extension installation complete: 1 successful, 1 failed" -and $ForegroundColor -eq "Gray" } + } + } + + Context "When extension list contains empty string" { + It "Should skip empty entries and write warning" { + $result = Invoke-VsCodeExtensionsImport -Extensions '["ms-vscode.powershell", "", "another.ext"]' + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "- Skipping empty extension entry" -and $ForegroundColor -eq "Yellow" -and $Verbosity -eq "Warning" } + } + } + + Context "When LogFile is provided" { + It "Should set PSDefaultParameterValues" { + $result = Invoke-VsCodeExtensionsImport -Extensions '["ms-vscode.powershell"]' -LogFile "test.log" + $result | Should -Be $true + # Note: PSDefaultParameterValues is set, but hard to assert directly + } + } + + Context "When extensions are piped" { + It "Should accept pipeline input and return true" { + $extensions = '["ms-vscode.powershell"]' + $result = $extensions | Invoke-VsCodeExtensionsImport + $result | Should -Be $true + } + } + + Context "When extension data is a single string" { + It "Should convert to array and install" { + $result = Invoke-VsCodeExtensionsImport -Extensions '"ms-vscode.powershell"' + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Installing 1 Visual Studio Code extensions" -and $ForegroundColor -eq "Gray" } + } + } + + Context "When unexpected data type" { + It "Should return false and write error" { + $result = Invoke-VsCodeExtensionsImport -Extensions 123 + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Unexpected extension data type" -and $Verbosity -eq "Error" } + } + } + + Context "When exception occurs during install" { + It "Should keep going and return true and write error" { + Mock Invoke-Command { throw "Install failed" } + $result = Invoke-VsCodeExtensionsImport -Extensions '["ext"]' + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error installing:" -and $Verbosity -eq "Error" } + } + } + + Context "When outer try-catch catches exception" { + It "Should return false and write error" { + Mock Find-VsCode { throw "Unexpected error" } + $result = Invoke-VsCodeExtensionsImport -Extensions '["ext"]' + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error importing VS Code configuration:" -and $Verbosity -eq "Error" } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Invoke-VsCodeExtensionsImport -Extensions '["ms-vscode.powershell"]' + $result | Should -Be $true + } + + It "Should work on Linux" { + Mock Find-VsCode { $null } + $result = Invoke-VsCodeExtensionsImport -Extensions '["ext"]' + $result | Should -Be $false + } + + It "Should work on macOS" { + Mock Find-VsCode { $null } + $result = Invoke-VsCodeExtensionsImport -Extensions '["ext"]' + $result | Should -Be $false + } + } +} diff --git a/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsImport.ps1 b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsImport.ps1 new file mode 100644 index 0000000..88c72f2 --- /dev/null +++ b/DevSetup/Private/3rdParty/VisualStudioCode/Invoke-VsCodeExtensionsImport.ps1 @@ -0,0 +1,96 @@ +Function Invoke-VsCodeExtensionsImport { + [CmdletBinding()] + [OutputType([bool])] + Param( + [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] + $Extensions, + [Parameter(Mandatory=$false)] + [string]$LogFile = $null + ) + + if (-not ([string]::IsNullOrEmpty($LogFile))) { + $PSDefaultParameterValues = @{ + 'Write-EZLog:LogFile' = $LogFile ; + } + } + + try { + if (-not $Extensions) { + Write-StatusMessage "No extensions provided" -Verbosity Warning + return $false + } + + $codePath = Find-VsCode + if (-not $codePath) { + Write-StatusMessage "Visual Studio Code executable not found" -Verbosity Error + return $false + } + + # Convert from JSON + try { + $ExtensionList = ($Extensions | ConvertFrom-Json) + } + catch { + Write-StatusMessage "Failed to parse JSON from decoded configuration: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + if (-not $ExtensionList) { + Write-StatusMessage "No extensions found in provided configuration" -Verbosity Warning + return $true + } + + # Handle both array and single string cases + if (-not ($ExtensionList -is [array])) { + if ($ExtensionList -is [string]) { + $ExtensionList = @($ExtensionList) + } else { + Write-StatusMessage "Unexpected extension data type: $($ExtensionList.GetType())" -Verbosity Error + return $false + } + } + + Write-StatusMessage "- Installing $($ExtensionList.Count) Visual Studio Code extensions..." -ForegroundColor Gray -Indent 4 + + $successCount = 0 + $failureCount = 0 + + # Install each extension + foreach ($Extension in $ExtensionList) { + if(([string]::IsNullOrEmpty(($Extension.Trim())))) { + Write-StatusMessage "- Skipping empty extension entry" -ForegroundColor Yellow -Verbosity Warning + continue + } + + Write-StatusMessage "- Installing extension: $Extension" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 6 + + try { + Invoke-Command -ScriptBlock { & $codePath --install-extension $Extension --force } *> $null + if ($LASTEXITCODE -eq 0) { + Write-StatusMessage "[OK]" -ForegroundColor Green + $successCount++ + } + else { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + $failureCount++ + } + } + catch { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + Write-StatusMessage "Error installing: $Extension - $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + $failureCount++ + } + } + + # Summary + Write-StatusMessage "- Extension installation complete: $successCount successful, $failureCount failed" -ForegroundColor Gray -Indent 4 + + return $true + } catch { + Write-StatusMessage "Error importing VS Code configuration: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } +} \ No newline at end of file diff --git a/DevSetup/Private/Commands/Export-DevSetupEnv.Tests.ps1 b/DevSetup/Private/Commands/Export-DevSetupEnv.Tests.ps1 index 80b8c04..fd90244 100644 --- a/DevSetup/Private/Commands/Export-DevSetupEnv.Tests.ps1 +++ b/DevSetup/Private/Commands/Export-DevSetupEnv.Tests.ps1 @@ -1,111 +1,214 @@ BeforeAll { + Function Write-EZLog { } . (Join-Path $PSScriptRoot "Export-DevSetupEnv.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Get-DevSetupEnvPath.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Get-DevSetupLocalEnvPath.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Get-DevSetupCommunityEnvPath.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Write-NewConfig.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") - if ($PSVersionTable.PSVersion.Major -eq 5) { - Mock Get-DevSetupEnvPath { "$TestDrive\DevSetup\DevSetupEnvs" } - Mock Get-DevSetupLocalEnvPath { "$TestDrive\DevSetup\DevSetupEnvs\local" } - Mock Get-DevSetupCommunityEnvPath { "$TestDrive\DevSetup\DevSetupEnvs\community" } - } elseif ($PSVersionTable.PSVersion.Major -ge 6) { - if ($IsWindows) { - Mock Get-DevSetupEnvPath { "$TestDrive\DevSetup\DevSetupEnvs" } - Mock Get-DevSetupLocalEnvPath { "$TestDrive\DevSetup\DevSetupEnvs\local" } - Mock Get-DevSetupCommunityEnvPath { "$TestDrive\DevSetup\DevSetupEnvs\community" } - } - if ($IsLinux) { - Mock Get-DevSetupEnvPath { "$TestDrive/home/testuser/DevSetup/DevSetupEnvs" } - Mock Get-DevSetupLocalEnvPath { "$TestDrive/home/testuser/DevSetup/DevSetupEnvs/local" } - Mock Get-DevSetupCommunityEnvPath { "$TestDrive/home/testuser/DevSetup/DevSetupEnvs/community" } - } - if ($IsMacOS) { - Mock Get-DevSetupEnvPath { "$TestDrive/Users/TestUser/DevSetup/DevSetupEnvs" } - Mock Get-DevSetupLocalEnvPath { "$TestDrive/Users/TestUser/DevSetup/DevSetupEnvs/local" } - Mock Get-DevSetupCommunityEnvPath { "$TestDrive/Users/TestUser/DevSetup/DevSetupEnvs/community" } - } - } - Mock Write-NewConfig { param($OutFile) $OutFile } - Mock Write-Host { } - Mock Write-Error { } + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Write-NewConfig.ps1") + Mock Get-DevSetupEnvPath { Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs" } + Mock Test-Path { $true } + Mock New-Item { } Mock Write-StatusMessage { } + Mock Write-NewConfig { $true } } Describe "Export-DevSetupEnv" { - Context "When called with a valid name" { - It "Should create the config file and return its path" { + Context "When exporting with Name parameter" { + It "Should create directory if not exists and call Write-NewConfig" { + Mock Test-Path { $false } # Directory doesn't exist + $expectedPath = Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "MyEnv.devsetup" + Mock Write-NewConfig { $expectedPath } $result = Export-DevSetupEnv -Name "MyEnv" - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - $expectedPath = "$TestDrive\DevSetup\DevSetupEnvs\local\MyEnv.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - $expectedPath = "$TestDrive/home/testuser/DevSetup/DevSetupEnvs/local/MyEnv.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - $expectedPath = "$TestDrive/Users/TestUser/DevSetup/DevSetupEnvs/local/MyEnv.devsetup" - } $result | Should -Be $expectedPath + Assert-MockCalled Get-DevSetupEnvPath -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") } + Assert-MockCalled New-Item -Exactly 1 -Scope It -ParameterFilter { $Path -eq (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") -and $ItemType -eq "Directory" } Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It -ParameterFilter { $OutFile -eq $expectedPath } - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "exported to" -and $ForegroundColor -eq "Green" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Configuration file exported to:" -and $ForegroundColor -eq "Green" } + } + } + + Context "When exporting with Name parameter and directory exists" { + It "Should not create directory and call Write-NewConfig" { + Mock Test-Path { $true } # Directory exists + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "MyEnv.devsetup") + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Name "MyEnv" + $result | Should -Be $expectedPath + Assert-MockCalled New-Item -Exactly 0 -Scope It + Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It } } - Context "When called with a valid path" { - It "Should create the config file and return its path" { - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - $result = Export-DevSetupEnv -Path "$TestDrive\MyCustomPath\MyEnv.devsetup" - $expectedPath = "$TestDrive\MyCustomPath\MyEnv.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - $result = Export-DevSetupEnv -Path "$TestDrive/MyCustomPath/MyEnv.devsetup" - $expectedPath = "$TestDrive/MyCustomPath/MyEnv.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - $result = Export-DevSetupEnv -Path "$TestDrive/MyCustomPath/MyEnv.devsetup" - $expectedPath = "$TestDrive/MyCustomPath/MyEnv.devsetup" - } + Context "When Name includes provider" { + It "Should parse provider and name correctly" { + Mock Test-Path { $true } + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "custom") "MyEnv.devsetup") + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Name "custom:MyEnv" $result | Should -Be $expectedPath + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "custom") } Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It -ParameterFilter { $OutFile -eq $expectedPath } - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "exported to" -and $ForegroundColor -eq "Green" } } - } + } - Context "When called with a name that needs sanitization" { - It "Should sanitize the name and warn" { + Context "When Name requires sanitization" { + It "Should sanitize name and warn" { + Mock Test-Path { $true } + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "DataScienceEnvironment.devsetup") + Mock Write-NewConfig { $expectedPath } $result = Export-DevSetupEnv -Name "Data Science Environment!" - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - $expectedPath = "$TestDrive\DevSetup\DevSetupEnvs\local\DataScienceEnvironment.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - $expectedPath = "$TestDrive/home/testuser/DevSetup/DevSetupEnvs/local/DataScienceEnvironment.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - $expectedPath = "$TestDrive/Users/TestUser/DevSetup/DevSetupEnvs/local/DataScienceEnvironment.devsetup" - } $result | Should -Be $expectedPath - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "sanitized" -and $ForegroundColor -eq "Yellow" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "EnvName sanitized from 'Data Science Environment!' to 'DataScienceEnvironment'" -and $ForegroundColor -eq "Yellow" } + Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It -ParameterFilter { $OutFile -eq $expectedPath } } } - Context "When called with a path that needs sanitization" { - It "Should sanitize the path and warn" { - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - $result = Export-DevSetupEnv -Path "$TestDrive\MyCustomPath\MyEnv!.devsetup" - $expectedPath = "$TestDrive\MyCustomPath\MyEnv.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - $result = Export-DevSetupEnv -Path "$TestDrive/MyCustomPath/MyEnv!.devsetup" - $expectedPath = "$TestDrive/MyCustomPath/MyEnv.devsetup" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - $result = Export-DevSetupEnv -Path "$TestDrive/MyCustomPath/MyEnv!.devsetup" - $expectedPath = "$TestDrive/MyCustomPath/MyEnv.devsetup" - } + Context "When Name does not require sanitization" { + It "Should not warn" { + Mock Test-Path { $true } + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "MyEnv.devsetup") + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Name "MyEnv" $result | Should -Be $expectedPath - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "sanitized" -and $ForegroundColor -eq "Yellow" } + Assert-MockCalled Write-StatusMessage -Exactly 0 -Scope It -ParameterFilter { $ForegroundColor -eq "Yellow" } } - } + } + + Context "When using Path parameter" { + It "Should create directory if not exists and call Write-NewConfig" { + Mock Test-Path { $false } # Directory doesn't exist + $customPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv.devsetup" + $expectedPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv.devsetup" + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Path $customPath + $result | Should -Be $expectedPath + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq (Join-Path $TestDrive "Custom") } + Assert-MockCalled New-Item -Exactly 1 -Scope It -ParameterFilter { $Path -eq (Join-Path $TestDrive "Custom") -and $ItemType -eq "Directory" } + Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It -ParameterFilter { $OutFile -eq $expectedPath } + } + } + + Context "When using Path parameter and directory exists" { + It "Should not create directory and call Write-NewConfig" { + Mock Test-Path { $true } # Directory exists + $customPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv.devsetup" + $expectedPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv.devsetup" + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Path $customPath + $result | Should -Be $expectedPath + Assert-MockCalled New-Item -Exactly 0 -Scope It + Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It + } + } + + Context "When Path requires sanitization" { + It "Should sanitize name and warn" { + Mock Test-Path { $true } + $customPath = Join-Path (Join-Path $TestDrive "Custom") "Data Science Environment!.devsetup" + $expectedPath = Join-Path (Join-Path $TestDrive "Custom") "DataScienceEnvironment.devsetup" + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Path $customPath + $result | Should -Be $expectedPath + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "EnvName sanitized from 'Data Science Environment!.devsetup' to 'DataScienceEnvironment.devsetup'" -and $ForegroundColor -eq "Yellow" } + } + } + + Context "When Path already has .devsetup extension" { + It "Should not add extension" { + Mock Test-Path { $true } + $customPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv.devsetup" + $expectedPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv.devsetup" + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Path $customPath + $result | Should -Be $expectedPath + Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It -ParameterFilter { $OutFile -eq $expectedPath } + } + } + + Context "When Path does not have .devsetup extension" { + It "Should add .devsetup extension" { + Mock Test-Path { $true } + $customPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv" + $expectedPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv.devsetup" + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Path $customPath + $result | Should -Be $expectedPath + Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It -ParameterFilter { $OutFile -eq $expectedPath } + } + } + + Context "When OutFile cannot be determined in Path parameter set" { + It "Should return null and write error" { + # Create a scenario where OutFile ends up null after all processing + # We'll simulate the Path parameter being valid but resulting in null OutFile + Mock Test-Path { $true } + Mock Write-NewConfig { } + + # Let's manually call the function with an edge case that could result in null OutFile + # by making Join-Path return null/empty + Mock Join-Path { $null } -ParameterFilter { $ChildPath -like "*.devsetup" } + + $customPath = Join-Path (Join-Path $TestDrive "Custom") "MyEnv" + $result = Export-DevSetupEnv -Path $customPath + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to determine output file path" -and $Verbosity -eq "Error" } + } + } Context "When Write-NewConfig fails" { - It "Should write error and return null" { - Mock Write-NewConfig { param($OutFile) $null } - $result = Export-DevSetupEnv -Name "FailEnv" + It "Should return null and write error" { + Mock Write-NewConfig { $null } + $result = Export-DevSetupEnv -Name "fail-env" $result | Should -Be $null Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to create configuration file" -and $Verbosity -eq "Error" } } } + + Context "When OutFile is not determined" { + It "Should return null and write error when DevSetupEnvPath is null" { + # This scenario targets the earlier check (line 88-89) + Mock Get-DevSetupEnvPath { $null } + $result = Export-DevSetupEnv -Name "no-path" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to determine DevSetup environment path" -and $Verbosity -eq "Error" } + } + } + + Context "When DryRun is specified" { + It "Should pass DryRun to Write-NewConfig" { + Mock Test-Path { $true } + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "MyEnv.devsetup") + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Name "MyEnv" -DryRun + $result | Should -Be $expectedPath + Assert-MockCalled Write-NewConfig -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Test-Path { $true } + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "MyEnv.devsetup") + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Name "MyEnv" + $result | Should -Be $expectedPath + } + + It "Should work on Linux" { + Mock Test-Path { $true } + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "MyEnv.devsetup") + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Name "MyEnv" + $result | Should -Be $expectedPath + } + + It "Should work on macOS" { + Mock Test-Path { $true } + $expectedPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "DevSetup") "DevSetupEnvs") "local") "MyEnv.devsetup") + Mock Write-NewConfig { $expectedPath } + $result = Export-DevSetupEnv -Name "MyEnv" + $result | Should -Be $expectedPath + } + } } diff --git a/DevSetup/Private/Commands/Export-DevSetupEnv.ps1 b/DevSetup/Private/Commands/Export-DevSetupEnv.ps1 index 286ca53..d2b4964 100644 --- a/DevSetup/Private/Commands/Export-DevSetupEnv.ps1 +++ b/DevSetup/Private/Commands/Export-DevSetupEnv.ps1 @@ -83,7 +83,13 @@ Function Export-DevSetupEnv { Write-StatusMessage "EnvName sanitized from '$Name' to '$SanitizedEnvName' (removed non-alphanumeric characters)" -ForegroundColor Yellow } - $BasePath = Join-Path -Path (Get-DevSetupEnvPath) -ChildPath $Provider + $DevSetupEnvPath = (Get-DevSetupEnvPath) + if($null -eq $DevSetupEnvPath) { + Write-StatusMessage "Failed to determine DevSetup environment path" -Verbosity Error + return $null + } + + $BasePath = Join-Path -Path $DevSetupEnvPath -ChildPath $Provider if(-not (Test-Path -Path $BasePath)) { New-Item -Path $BasePath -ItemType Directory -Force | Out-Null } diff --git a/DevSetup/Private/Commands/Install-DevSetupEnv.Tests.ps1 b/DevSetup/Private/Commands/Install-DevSetupEnv.Tests.ps1 index a1fe22c..a037e0b 100644 --- a/DevSetup/Private/Commands/Install-DevSetupEnv.Tests.ps1 +++ b/DevSetup/Private/Commands/Install-DevSetupEnv.Tests.ps1 @@ -1,101 +1,406 @@ BeforeAll { . (Join-Path $PSScriptRoot "Install-DevSetupEnv.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Powershell\Install-PowershellModules.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Chocolatey\Install-ChocolateyPackages.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Scoop\Install-ScoopComponents.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Read-ConfigurationFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Read-DevSetupEnvFile.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Get-DevSetupEnvPath.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Get-DevSetupLocalEnvPath.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1") - if ($PSVersionTable.PSVersion.Major -eq 5) { - Mock Get-DevSetupEnvPath { "C:\DevSetup" } - } elseif ($PSVersionTable.PSVersion.Major -ge 6) { - if ($IsWindows) { - Mock Get-DevSetupEnvPath { "C:\DevSetup" } - } - if ($IsLinux) { - Mock Get-DevSetupEnvPath { "/home/testuser/devsetup" } - } - if ($IsMacOS) { - Mock Get-DevSetupEnvPath { "/Users/TestUser/devsetup" } - } - } - Mock Test-Path { $true } - Mock Read-ConfigurationFile { } - Mock Install-PowershellModules { } - Mock Install-ChocolateyPackages { } - Mock Install-ScoopComponents { } - Mock Write-Host { } - Mock Write-Error { } - Mock Write-Warning { } - Mock Invoke-Command { } - Mock Invoke-Expression { } + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Scoop\Invoke-ScoopComponentInstall.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Chocolatey\Invoke-ChocolateyPackageInstall.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Powershell\Invoke-PowershellModulesInstall.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Homebrew\Invoke-HomebrewComponentsInstall.ps1") Mock Write-StatusMessage { } + Mock Get-DevSetupEnvPath { Join-Path $TestDrive "DevSetup" | Join-Path -ChildPath "DevSetupEnvs" } + Mock Get-DevSetupLocalEnvPath { Join-Path $TestDrive "DevSetup" | Join-Path -ChildPath "LocalEnvs" } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Invoke-PowershellModulesInstall { } + Mock Invoke-ChocolateyPackageInstall { } + Mock Invoke-ScoopComponentInstall { } + Mock Invoke-HomebrewComponentsInstall { } Mock Test-OperatingSystem { $true } + Mock Invoke-WebRequest { } + Mock Read-Host { "Y" } + $Script:LASTEXITCODE = 0 + Mock Invoke-Command { $script:LASTEXITCODE = 0 } } Describe "Install-DevSetupEnv" { - Context "When environment file does not exist" { - It "Should write error and return" { + Context "Basic Name parameter usage" { + It "Should handle simple environment name correctly" { + $expectedPath = Join-Path (Join-Path $TestDrive "DevSetup" | Join-Path -ChildPath "DevSetupEnvs") "local" | Join-Path -ChildPath "myenv.devsetup" + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Get-DevSetupEnvPath -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $expectedPath } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $Config -eq $expectedPath } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Installing DevSetup environment from:" -and $ForegroundColor -eq "Cyan" } + } + } + + Context "Provider parsing in Name parameter" { + It "Should correctly parse provider from name with colon" { + $expectedPath = Join-Path (Join-Path $TestDrive "DevSetup" | Join-Path -ChildPath "DevSetupEnvs") "github" | Join-Path -ChildPath "myenv.devsetup" + Install-DevSetupEnv -Name "github:myenv" + Assert-MockCalled Get-DevSetupEnvPath -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $expectedPath } + } + } + + Context "Complex provider names" { + It "Should handle multiple colons in provider name" { + $expectedPath = Join-Path (Join-Path $TestDrive "DevSetup" | Join-Path -ChildPath "DevSetupEnvs") "github" | Join-Path -ChildPath "org.devsetup" + Install-DevSetupEnv -Name "github:org:repo:myenv" + Assert-MockCalled Get-DevSetupEnvPath -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $expectedPath } + } + } + + Context "Path resolution failures" { + It "Should handle Get-DevSetupEnvPath exceptions gracefully" { + Mock Get-DevSetupEnvPath { throw "Path resolution failed" } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get environment path" -and $Verbosity -eq "Error" } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "Direct path specification" { + BeforeEach { + $script:callCount = 0 + Mock Test-Path { + switch($script:callCount) { + 0 { $script:callCount++; return $true } + 1 { $script:callCount++; return $true } + default { $script:callCount++; return $true } + } + } + } + It "Should accept and validate custom file paths" { + $testPath = Join-Path $TestDrive "custom" | Join-Path -ChildPath "path" | Join-Path -ChildPath "config.devsetup" + Install-DevSetupEnv -Path $testPath + Assert-MockCalled Test-Path -Exactly 2 -Scope It + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $Config -eq $testPath } + } + } + + Context "Invalid path handling" { + It "Should report error for non-existent paths" { + $testPath = Join-Path $TestDrive "missing.devsetup" + Mock Test-Path { $false } -ParameterFilter { $Path -eq $testPath } + Install-DevSetupEnv -Path $testPath + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Invalid Path provided" -and $Verbosity -eq "Error" } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "URL validation" { + It "Should reject URLs not pointing to .devsetup files" { + Install-DevSetupEnv -Url "https://example.com/config.json" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "URL must point to a .devsetup file" -and $Verbosity -eq "Error" } + Assert-MockCalled Invoke-WebRequest -Exactly 0 -Scope It + } + } + + Context "URL download scenarios" { + It "Should download new files from valid URLs" { + $expectedPath = Join-Path (Join-Path $TestDrive "DevSetup" | Join-Path -ChildPath "LocalEnvs") "remote.devsetup" + Mock Test-Path { $false } -ParameterFilter { $Path -eq $expectedPath } + Install-DevSetupEnv -Url "https://example.com/remote.devsetup" + Assert-MockCalled Get-DevSetupLocalEnvPath -Exactly 1 -Scope It + Assert-MockCalled Invoke-WebRequest -Exactly 1 -Scope It -ParameterFilter { $Uri -eq "https://example.com/remote.devsetup" -and $OutFile -eq $expectedPath } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Downloading DevSetup environment from:" -and $ForegroundColor -eq "Cyan" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Saving Devsetup environment file to:" -and $ForegroundColor -eq "Cyan" } + } + } + + Context "File overwrite prompts" { + BeforeEach { + $script:callCount = 0 + Mock Test-Path { + switch($script:callCount) { + 0 { $script:callCount++; return $true } + 1 { $script:callCount++; return $true } + default { $script:callCount++; return $true } + } + } + } + It "Should handle user confirmation for overwriting existing files" { + Mock Read-Host { "Y" } + Install-DevSetupEnv -Url "https://example.com/existing.devsetup" + Assert-MockCalled Read-Host -Exactly 1 -Scope It + Assert-MockCalled Invoke-WebRequest -Exactly 1 -Scope It + } + } + + Context "User decline overwrite" { + It "Should respect user choice to not overwrite files" { + $expectedPath = Join-Path (Join-Path $TestDrive "DevSetup" | Join-Path -ChildPath "LocalEnvs") "existing.devsetup" + Mock Test-Path { $true } -ParameterFilter { $Path -eq $expectedPath } + Mock Read-Host { "N" } + Install-DevSetupEnv -Url "https://example.com/existing.devsetup" + Assert-MockCalled Read-Host -Exactly 1 -Scope It + Assert-MockCalled Invoke-WebRequest -Exactly 0 -Scope It + } + } + + Context "Download failures" { + It "Should handle network errors during download" { Mock Test-Path { $false } - $result = Install-DevSetupEnv -Name "missing-env" - $result | Should -Be $null + Mock Invoke-WebRequest { throw "Network connection failed" } + Install-DevSetupEnv -Url "https://example.com/config.devsetup" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to download devsetup env file" -and $Verbosity -eq "Error" } + } + } + + Context "Local path resolution errors" { + It "Should handle Get-DevSetupLocalEnvPath failures" { + Mock Get-DevSetupLocalEnvPath { throw "Local path resolution error" } + Install-DevSetupEnv -Url "https://example.com/config.devsetup" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get environment path" -and $Verbosity -eq "Error" } + } + } + + Context "Missing environment files" { + It "Should detect and report missing .devsetup files" { + Mock Test-Path { $false } -ParameterFilter { $Path -match "\.devsetup$" } + Install-DevSetupEnv -Name "missing" Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Environment file not found" -and $Verbosity -eq "Error" } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "YAML parsing errors" { + It "Should handle Read-DevSetupEnvFile exceptions" { + Mock Read-DevSetupEnvFile { throw "YAML syntax error" } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to read or parse environment file" -and $Verbosity -eq "Error" } + Assert-MockCalled Invoke-PowershellModulesInstall -Exactly 0 -Scope It + } + } + + Context "Invalid YAML content" { + It "Should detect null return from Read-DevSetupEnvFile" { + Mock Read-DevSetupEnvFile { $null } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to parse YAML configuration" -and $Verbosity -eq "Error" } + Assert-MockCalled Invoke-PowershellModulesInstall -Exactly 0 -Scope It + } + } + + Context "PowerShell module installation failures" { + It "Should handle Invoke-PowershellModulesInstall exceptions" { + Mock Invoke-PowershellModulesInstall { throw "Module installation failed" } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "An error occurred during PowerShell module installation" -and $Verbosity -eq "Error" } + Assert-MockCalled Invoke-ChocolateyPackageInstall -Exactly 0 -Scope It + } + } + + Context "Windows platform detection" { + It "Should invoke Windows-specific package managers on Windows" { + Mock Test-OperatingSystem { $true } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Invoke-PowershellModulesInstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageInstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentInstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-HomebrewComponentsInstall -Exactly 0 -Scope It + } + } + + Context "Non-Windows platform detection" { + It "Should invoke Homebrew on non-Windows platforms" { + Mock Test-OperatingSystem { $false } -ParameterFilter { $Windows } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Invoke-PowershellModulesInstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageInstall -Exactly 0 -Scope It + Assert-MockCalled Invoke-ScoopComponentInstall -Exactly 0 -Scope It + Assert-MockCalled Invoke-HomebrewComponentsInstall -Exactly 1 -Scope It + } + } + + Context "Chocolatey installation failures" { + It "Should handle Invoke-ChocolateyPackageInstall exceptions" { + Mock Invoke-ChocolateyPackageInstall { throw "Chocolatey installation error" } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "An error occurred during Chocolatey package installation" -and $Verbosity -eq "Error" } + Assert-MockCalled Invoke-ScoopComponentInstall -Exactly 0 -Scope It + } + } + + Context "Scoop installation failures" { + It "Should handle Invoke-ScoopComponentInstall exceptions" { + Mock Invoke-ScoopComponentInstall { throw "Scoop installation error" } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "An error occurred during Scoop component installation" -and $Verbosity -eq "Error" } + } + } + + Context "Homebrew installation failures" { + It "Should handle Invoke-HomebrewComponentsInstall exceptions" { + Mock Test-OperatingSystem { $false } -ParameterFilter { $Windows } + Mock Invoke-HomebrewComponentsInstall { throw "Homebrew installation error" } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "An error occurred during Homebrew component installation" -and $Verbosity -eq "Error" } + } + } + + Context "Dry run mode" { + It "Should propagate DryRun flag to all installation functions" { + Install-DevSetupEnv -Name "myenv" -DryRun + Assert-MockCalled Invoke-PowershellModulesInstall -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + Assert-MockCalled Invoke-ChocolateyPackageInstall -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + Assert-MockCalled Invoke-ScoopComponentInstall -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + } + } + + Context "Simple command execution" { + BeforeEach { + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ commands = @(@{ command = "echo 'Hello World'"; packageName = "greeter" }) } } } + } + It "Should execute commands without parameters" { + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Executing configuration commands" -and $ForegroundColor -eq "Cyan" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Executing command for: greeter" -and $ForegroundColor -eq "Gray" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command completed successfully" -and $Verbosity -eq "Verbose" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command greeter completed successfully" } + } + } + + Context "Simple command execution" { + BeforeEach { + $script:LASTEXITCODE = 1 + Mock Invoke-Command { $script:LASTEXITCODE = 1; return "Simulated command failure" } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ commands = @(@{ command = "echo 'Hello World'"; packageName = "greeter" }) } } } + } + It "Should execute commands without parameters and write error and continue when command returns non-zero exit code" { + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command failed with exit code" -and $Verbosity -eq "Error" } + } + } + + Context "Simple command execution" { + BeforeEach { + Mock Invoke-Command { throw "Simulated command failure" } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ commands = @(@{ command = "echo 'Hello World'"; packageName = "greeter" }) } } } + } + It "Should write status and continue when command throws exception" { + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command execution failed" -and $Verbosity -eq "Error" } + } + } + + Context "Command execution with hashtable parameters" { + It "Should handle commands with hashtable parameter objects" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ command = "setup.exe"; packageName = "installer"; params = @{ arg1 = "value1"; arg2 = "value2" } }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Parameter: arg1 = value1" -and $Verbosity -eq "Debug" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Parameter: arg2 = value2" -and $Verbosity -eq "Debug" } + } + } + + Context "Command execution with PSCustomObject parameters" { + It "Should handle commands with PSCustomObject parameter objects" { + $paramsObj = [PSCustomObject]@{ setting1 = "config1"; setting2 = "config2" } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ command = "configure.exe"; packageName = "config"; params = $paramsObj }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Parameter: setting1 = config1" -and $Verbosity -eq "Debug" } + } + } + + Context "Successful command execution" { + It "Should report successful command completion" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ command = "successful.exe"; packageName = "success" }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command completed successfully" -and $Verbosity -eq "Verbose" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command success completed successfully" -and $ForegroundColor -eq "Gray" } + } + } + + Context "Command execution with exit code failure" { + It "Should detect and report non-zero exit codes" { + Mock Invoke-Command { $script:LASTEXITCODE = 2; return "Command failed with error" } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ command = "failing.exe"; packageName = "failure" }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command failed with exit code 2" -and $Verbosity -eq "Error" } + } + } + + Context "Command execution with exit code failure and params" { + It "Should detect and report non-zero exit codes" { + Mock Invoke-Command { $script:LASTEXITCODE = 2; return "Command failed with error" } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ command = "failing.exe"; packageName = "failure"; params = @{ arg1 = "value1"; arg2 = "value2" } }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command failed with exit code 2" -and $Verbosity -eq "Error" } + } + } + + Context "Command execution exceptions" { + It "Should handle Invoke-Command exceptions" { + Mock Invoke-Command { throw "Command execution error" } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ command = "error.exe"; packageName = "error" }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command execution error" -and $Verbosity -eq "Error" } + } + } + + Context "Command execution exceptions with params" { + It "Should handle Invoke-Command exceptions" { + Mock Invoke-Command { throw "Command execution error" } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ command = "error.exe"; packageName = "error"; params = @{ setting1 = "value1"; setting2 = "value2" } }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Command execution error" -and $Verbosity -eq "Error" } + } + } + + Context "Invalid command entries" { + It "Should skip commands missing the command property" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ commands = @(@{ packageName = "invalid" }) } } } + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Skipping command entry with missing command property" -and $Verbosity -eq "Warning" } + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + } + + Context "Empty command configurations" { + It "Should handle configurations with no commands" { + Install-DevSetupEnv -Name "myenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "No commands found in configuration to execute" -and $ForegroundColor -eq "Gray" } + } + } + + Context "Parameter validation - empty Name" { + It "Should reject empty Name parameter" { + { Install-DevSetupEnv -Name "" } | Should -Throw + } + } + + Context "Parameter validation - empty Path" { + It "Should reject empty Path parameter" { + { Install-DevSetupEnv -Path "" } | Should -Throw + } + } + + Context "Parameter validation - empty Url" { + It "Should reject empty Url parameter" { + { Install-DevSetupEnv -Url "" } | Should -Throw + } + } + + Context "Parameter validation - no parameters" { + It "Should require at least one parameter set" { + { Install-DevSetupEnv } | Should -Throw } } - Context "When YAML parsing fails" { - It "Should write error and return" { - Mock Test-Path { $true } - Mock Read-ConfigurationFile { $null } - $result = Install-DevSetupEnv -Name "bad-yaml" - $result | Should -Be $null - Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to parse YAML" -and $Verbosity -eq "Error" } - } - } - - Context "When all dependencies install and no commands are present" { - It "Should install dependencies and write status" { - Mock Test-Path { $true } - Mock Read-ConfigurationFile { @{ devsetup = @{ } } } - $result = Install-DevSetupEnv -Name "basic-env" - $result | Should -Be $null - Assert-MockCalled Install-PowershellModules -Exactly 1 -Scope It - Assert-MockCalled Install-ChocolateyPackages -Exactly 1 -Scope It - Assert-MockCalled Install-ScoopComponents -Exactly 1 -Scope It - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "No commands found" } - } - } - - Context "When commands are present and executed" { - It "Should execute all commands" { - $commands = @( - @{ command = "echo Hello"; packageName = "git" }, - @{ command = "echo World"; packageName = "nodejs" } - ) - Mock Test-Path { $true } - Mock Read-ConfigurationFile { @{ devsetup = @{ commands = $commands } } } - $result = Install-DevSetupEnv -Name "cmd-env" - $result | Should -Be $null - Assert-MockCalled Invoke-Expression -Exactly 2 -Scope It - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Executing command for: git" } - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Executing command for: nodejs" } - } - } - - Context "When a command entry is missing the command property" { - It "Should skip and warn" { - $commands = @( - @{ packageName = "git" }, - @{ command = "echo World"; packageName = "nodejs" } - ) - Mock Test-Path { $true } - Mock Read-ConfigurationFile { @{ devsetup = @{ commands = $commands } } } - $result = Install-DevSetupEnv -Name "missing-cmd" - $result | Should -Be $null - Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "missing command property" -and $Verbosity -eq "Warning" } - Assert-MockCalled Invoke-Expression -Exactly 1 -Scope It + Context "Parameter validation - conflicting parameters" { + It "Should reject multiple parameter sets" { + { Install-DevSetupEnv -Name "test" -Path "test.devsetup" } | Should -Throw } } } \ No newline at end of file diff --git a/DevSetup/Private/Commands/Install-DevSetupEnv.ps1 b/DevSetup/Private/Commands/Install-DevSetupEnv.ps1 index d281f5b..9b7c33e 100644 --- a/DevSetup/Private/Commands/Install-DevSetupEnv.ps1 +++ b/DevSetup/Private/Commands/Install-DevSetupEnv.ps1 @@ -38,8 +38,8 @@ - Processes dependencies in a specific order: PowerShell modules, Chocolatey packages, then Scoop components - Commands are executed after all package installations are complete - Individual installation failures do not stop the overall process - - Uses Read-ConfigurationFile to parse YAML configuration - - Leverages Install-PowershellModules, Install-ChocolateyPackages, and Install-ScoopComponents functions + - Uses Read-DevSetupEnvFile to parse YAML configuration + - Leverages Install-PowershellModules, Invoke-ChocolateyPackageInstall, and Invoke-ScoopComponentInstall functions - Custom commands are executed using Invoke-CommandFromEnv function - Provides detailed console output with color-coded status messages - Skips command entries that are missing the required command property @@ -70,7 +70,7 @@ Function Install-DevSetupEnv { $YamlFile = $null if($PSBoundParameters.ContainsKey('Name')) { - $Provider = "local" + $Provider = $null if($Name -like "*:*") { $parts = $Name.Split(":") @@ -78,7 +78,19 @@ Function Install-DevSetupEnv { $Provider = $parts[0] } - $YamlFile = Join-Path -Path (Join-Path -Path (Get-DevSetupEnvPath) -ChildPath $Provider) -ChildPath "$Name.devsetup" + if ([string]::IsNullOrWhiteSpace($Provider)) { + $Provider = "local" + } + + try { + $envPath = Get-DevSetupEnvPath + } catch { + Write-StatusMessage "Failed to get environment path. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + + $YamlFile = Join-Path -Path (Join-Path -Path $envPath -ChildPath $Provider) -ChildPath "$Name.devsetup" } elseif($PSBoundParameters.ContainsKey('Path')) { if(-not (Test-Path -Path $Path)) { Write-StatusMessage "Invalid Path provided" -Verbosity Error @@ -86,14 +98,30 @@ Function Install-DevSetupEnv { } $YamlFile = $Path } elseif($PSBoundParameters.ContainsKey('Url')) { + try { + $envPath = Get-DevSetupLocalEnvPath + } catch { + Write-StatusMessage "Failed to get environment path. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + $FileName = Split-Path $Url -Leaf + if(-not $FileName.EndsWith(".devsetup")) { + Write-StatusMessage "URL must point to a .devsetup file" -Verbosity Error + return + } + Write-StatusMessage "Downloading DevSetup environment from:" -ForegroundColor Cyan Write-StatusMessage "- $Url" -Indent 2 -ForegroundColor Gray - $YamlFile = Join-Path -Path (Get-DevSetupLocalEnvPath) -ChildPath $FileName + + $YamlFile = Join-Path -Path $envPath -ChildPath $FileName + Write-StatusMessage "Saving Devsetup environment file to:" -ForegroundColor Cyan Write-StatusMessage "- $YamlFile" -Indent 2 -ForegroundColor Gray + if((Test-Path -Path $YamlFile)) { - Write-Warning "File $YamlFile already exists" + Write-StatusMessage "File $YamlFile already exists" -Verbosity Warning do { if(($sAnswer = Read-Host "Overwrite existing file and continue? [Y/N]") -eq '') { $sAnswer = 'N' } } until ($sAnswer.ToUpper()[0] -match '[yYnN]') @@ -118,7 +146,13 @@ Function Install-DevSetupEnv { Write-StatusMessage "- $YamlFile`n" -Indent 2 -ForegroundColor Gray # Read the configuration from the YAML file - $YamlData = Read-ConfigurationFile -Config $YamlFile + try { + $YamlData = Read-DevSetupEnvFile -Config $YamlFile + } catch { + Write-StatusMessage "Failed to read or parse environment file: $YamlFile. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } # Check if YAML data was successfully parsed if ($null -eq $YamlData) { @@ -127,17 +161,41 @@ Function Install-DevSetupEnv { } # Install PowerShell module dependencies - Install-PowershellModules -YamlData $YamlData | Out-Null + try { + Invoke-PowerShellModulesInstall -YamlData $YamlData -DryRun:$DryRun | Out-Null + } catch { + Write-StatusMessage "An error occurred during PowerShell module installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } if ((Test-OperatingSystem -Windows)) { # Install Chocolatey package dependencies - Install-ChocolateyPackages -YamlData $YamlData | Out-Null + try { + Invoke-ChocolateyPackageInstall -YamlData $YamlData -DryRun:$DryRun | Out-Null + } catch { + Write-StatusMessage "An error occurred during Chocolatey package installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } # Install Scoop package dependencies - Install-ScoopComponents -YamlData $YamlData | Out-Null + try { + Invoke-ScoopComponentInstall -YamlData $YamlData -DryRun:$DryRun | Out-Null + } catch { + Write-StatusMessage "An error occurred during Scoop component installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } } else { # Install Homebrew package dependencies - Invoke-HomebrewComponentsInstall -YamlData $YamlData -DryRun:$DryRun | Out-Null + try { + Invoke-HomebrewComponentsInstall -YamlData $YamlData -DryRun:$DryRun | Out-Null + } catch { + Write-StatusMessage "An error occurred during Homebrew component installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } } # Execute any commands defined in the configuration if ($YamlData.devsetup.commands -and $YamlData.devsetup.commands.Count -gt 0) { @@ -146,7 +204,48 @@ Function Install-DevSetupEnv { foreach ($commandEntry in $YamlData.devsetup.commands) { if ($commandEntry.command) { Write-StatusMessage "- Executing command for: $($commandEntry.packageName)" -Indent 2 -ForegroundColor Gray - Invoke-Expression -Command $commandEntry.command *> $null + if ($commandEntry.params) { + Write-StatusMessage "Running command: $Command with parameters: " -Verbosity Debug + $CommandParams = @{} + if ($commandEntry.params -is [hashtable]) { + foreach ($param in $commandEntry.params.GetEnumerator()) { + $CommandParams[$param.Key] = $param.Value + Write-StatusMessage " - Parameter: $($param.Key) = $($param.Value)" -Verbosity Debug + } + } elseif ($commandEntry.params -is [PSCustomObject]) { + foreach ($param in $commandEntry.params.PSObject.Properties) { + $CommandParams[$param.Name] = $param.Value + Write-StatusMessage " - Parameter: $($param.Name) = $($param.Value)" -Verbosity Debug + } + } + $CommandParams.LogFile = $PSDefaultParameterValues['Write-EZLog:LogFile'] + $Command = $commandEntry.command + try { + $result = Invoke-Command -ScriptBlock { & $Command @CommandParams } + if ($LASTEXITCODE -ne 0) { + Write-StatusMessage "Command failed with exit code $LASTEXITCODE : $result" -Verbosity Error + } else { + Write-StatusMessage "Command completed successfully." -Verbosity Verbose + Write-StatusMessage "- Command $($commandEntry.packageName) completed successfully." -ForegroundColor Gray -Indent 2 + } + } catch { + Write-StatusMessage "Command execution failed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + } + } else { + try { + Invoke-Command -ScriptBlock { & $commandEntry.command *> $null } + if ($LASTEXITCODE -ne 0) { + Write-StatusMessage "Command failed with exit code $LASTEXITCODE" -Verbosity Error + } else { + Write-StatusMessage "Command completed successfully." -Verbosity Verbose + Write-StatusMessage "- Command $($commandEntry.packageName) completed successfully." -ForegroundColor Gray -Indent 2 + } + } catch { + Write-StatusMessage "Command execution failed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + } + } } else { Write-StatusMessage "Skipping command entry with missing command property" -Verbosity Warning } diff --git a/DevSetup/Private/Commands/Show-DevSetupEnvList.Tests.ps1 b/DevSetup/Private/Commands/Show-DevSetupEnvList.Tests.ps1 index 63eb0e9..5aa37ef 100644 --- a/DevSetup/Private/Commands/Show-DevSetupEnvList.Tests.ps1 +++ b/DevSetup/Private/Commands/Show-DevSetupEnvList.Tests.ps1 @@ -198,6 +198,63 @@ Describe "Show-DevSetupEnvList" { } } + Context "When current platform cannot be detected" { + It "Should default to windows platform" { + Mock Format-PrettyTable { } + Mock Test-OperatingSystem { param($Windows, $Linux, $MacOS) $false } # All OS checks return false + Show-DevSetupEnvList -Platform "current" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Filtering for platform: windows" } + Assert-MockCalled Format-PrettyTable -Exactly 1 -Scope It -ParameterFilter { $Rows.Count -eq 1 } + } + } + + Context "When using the Installed parameter" { + It "Should add installed only message to status output" { + Mock Format-PrettyTable { } + Show-DevSetupEnvList -Platform "all" -Installed + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match ", installed only" } + Assert-MockCalled Format-PrettyTable -Exactly 1 -Scope It + } + } + + Context "When environment has no platform specified" { + It "Should display 'Not specified' for platform" { + Mock Format-PrettyTable { param($Rows) + # Check that at least one row has "Not specified" as platform + $hasNotSpecified = $Rows | Where-Object { $_.Platform -eq "Not specified" } + if (-not $hasNotSpecified) { + throw "Expected at least one environment with 'Not specified' platform" + } + } + Mock ConvertFrom-Json { + @( + @{ name = "EnvNoPlat"; version = "1.0"; provider = "local"; file = "envnoplat.yaml" } # No platform property + ) + } + Show-DevSetupEnvList -Platform "all" + Assert-MockCalled Format-PrettyTable -Exactly 1 -Scope It + } + } + + Context "When environment has no version specified" { + It "Should display 'Unknown' for version" { + Mock Format-PrettyTable { param($Rows) + # Check that at least one row has "Unknown" as version + $hasUnknown = $Rows | Where-Object { $_.Version -eq "Unknown" } + if (-not $hasUnknown) { + throw "Expected at least one environment with 'Unknown' version" + } + } + Mock ConvertFrom-Json { + @( + @{ name = "EnvNoVer"; platform = "windows"; provider = "local"; file = "envnover.yaml" } # No version property + ) + } + Show-DevSetupEnvList -Platform "all" + Assert-MockCalled Format-PrettyTable -Exactly 1 -Scope It + } + } + Context "When environments are found" { It "Should display the environments table and count" { Mock Format-PrettyTable { } diff --git a/DevSetup/Private/Commands/Show-ExplainDevSetupEnv.Tests.ps1 b/DevSetup/Private/Commands/Show-ExplainDevSetupEnv.Tests.ps1 new file mode 100644 index 0000000..91851fd --- /dev/null +++ b/DevSetup/Private/Commands/Show-ExplainDevSetupEnv.Tests.ps1 @@ -0,0 +1,428 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Show-ExplainDevSetupEnv.ps1") + . (Join-Path $PSScriptRoot "..\..\Private\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Private\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\Private\Utils\Get-DevSetupEnvPath.ps1") + . (Join-Path $PSScriptRoot "..\..\Private\Utils\Format-PrettyTable.ps1") + Mock Write-StatusMessage { } + Mock Get-DevSetupEnvPath { Join-Path $TestDrive "devsetup" } + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test description" + version = "1.0.0" + createdBy = "Test User" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ + name = "Windows" + } + } + dependencies = @{ + chocolatey = @{ + packages = @(@{ name = "git"; version = "2.0.0" }) + } + powershell = @{ + modules = @(@{ name = "PSScriptAnalyzer"; minimumVersion = "1.0.0" }) + } + } + commands = @(@{ name = "test command" }) + } + } + } + Mock Format-PrettyTable { } + Mock Test-Path { return $true } +} + +Describe "Show-ExplainDevSetupEnv" { + + Context "When name is provided without provider" { + It "Should use local provider and construct correct path" { + $expectedPath = Join-Path (Join-Path $TestDrive "devsetup") "local" | Join-Path -ChildPath "testenv.devsetup" + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Get-DevSetupEnvPath -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $expectedPath } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $Config -eq $expectedPath } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Reading environment file" -and $ForegroundColor -eq "Gray" } + } + } + + Context "When name is provided with provider" { + It "Should parse provider and name correctly" { + $expectedPath = Join-Path (Join-Path $TestDrive "devsetup") "remote" | Join-Path -ChildPath "testenv.devsetup" + Show-ExplainDevSetupEnv -Name "remote:testenv" + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $expectedPath } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $Config -eq $expectedPath } + } + } + + Context "When name has multiple colons" { + It "Should use first part as provider" { + $expectedPath = Join-Path (Join-Path $TestDrive "devsetup") "remote" | Join-Path -ChildPath "extra.devsetup" + Show-ExplainDevSetupEnv -Name "remote:extra:testenv" + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $expectedPath } + } + } + + Context "When path is provided and file exists" { + It "Should use provided path and extract name" { + $testFile = Join-Path $TestDrive "test.devsetup" + New-Item -ItemType File -Path $testFile + Show-ExplainDevSetupEnv -Path $testFile + Assert-MockCalled Test-Path -Exactly 2 -Scope It -ParameterFilter { $Path -eq $testFile } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $Config -eq $testFile } + } + } + + Context "When path is provided but file does not exist" { + It "Should write error and return early" { + $testPath = Join-Path $TestDrive "nonexistent.devsetup" + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $testPath } + Show-ExplainDevSetupEnv -Path $testPath + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Invalid Path provided" -and $Verbosity -eq "Error" } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 0 -Scope It + Assert-MockCalled Format-PrettyTable -Exactly 0 -Scope It + } + } + + Context "When constructed file path does not exist" { + It "Should write error and return early" { + Mock Test-Path { return $false } -ParameterFilter { $Path -match "\.devsetup$" } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Environment file not found" -and $Verbosity -eq "Error" } + Assert-MockCalled Read-DevSetupEnvFile -Exactly 0 -Scope It + Assert-MockCalled Format-PrettyTable -Exactly 0 -Scope It + } + } + + Context "When Read-DevSetupEnvFile returns null" { + It "Should write error and return early" { + Mock Read-DevSetupEnvFile { return $null } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to read or parse" -and $Verbosity -eq "Error" } + Assert-MockCalled Format-PrettyTable -Exactly 0 -Scope It + } + } + + Context "When Read-DevSetupEnvFile throws exception" { + It "Should write error and return early" { + Mock Read-DevSetupEnvFile { throw "Parse error" } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to read or parse" -and $Verbosity -eq "Error" } + } + } + + Context "When YAML data is missing devsetup section" { + It "Should handle gracefully" { + Mock Read-DevSetupEnvFile { return @{ } } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 0 -Scope It + } + } + + Context "When configuration section is missing" { + It "Should handle missing configuration gracefully" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @(@{ name = "git"; version = "2.0.0" }) + } + } + commands = @() + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Malformed devsetup environment file" -and $Verbosity -eq "Warning" } + } + } + + Context "When dependencies section is missing" { + It "Should handle missing dependencies gracefully" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test" + version = "1.0" + createdBy = "Test" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ name = "Windows" } + } + commands = @() + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Malformed devsetup environment file" } + } + } + + Context "When commands section is missing" { + It "Should handle missing commands gracefully" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test" + version = "1.0" + createdBy = "Test" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ name = "Windows" } + } + dependencies = @{ + chocolatey = @{ + packages = @(@{ name = "git"; version = "2.0.0" }) + } + } + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 2 -Scope It + } + } + + Context "When dependencies have empty packages and modules" { + It "Should handle empty collections and show no packages message" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test" + version = "1.0" + createdBy = "Test" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ name = "Windows" } + } + dependencies = @{ + chocolatey = @{ packages = @() } + powershell = @{ modules = @() } + } + commands = @() + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "No packages or modules defined" -and $ForegroundColor -eq "Yellow" } + } + } + + Context "When dependencies have only packages" { + It "Should display packages table correctly" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test" + version = "1.0" + createdBy = "Test" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ name = "Windows" } + } + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "2.0.0" } + @{ name = "nodejs"; version = "18.0.0" } + ) + } + } + commands = @() + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 2 -Scope It + } + } + + Context "When dependencies have only modules" { + It "Should display modules table correctly" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test" + version = "1.0" + createdBy = "Test" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ name = "Windows" } + } + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "PSScriptAnalyzer"; minimumVersion = "1.0.0" } + ) + } + } + commands = @() + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 2 -Scope It + } + } + + Context "When dependencies have mixed packages and modules" { + It "Should display all items with correct colors" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test" + version = "1.0" + createdBy = "Test" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ name = "Windows" } + } + dependencies = @{ + chocolatey = @{ + packages = @(@{ name = "git"; version = "2.0.0" }) + } + powershell = @{ + modules = @(@{ name = "PSScriptAnalyzer"; minimumVersion = "1.0.0" }) + } + scoop = @{ + packages = @(@{ name = "curl"; version = "1.0.0" }) + } + homebrew = @{ + packages = @(@{ name = "wget"; version = "1.0.0" }) + } + } + commands = @(@{ name = "test command" }) + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 2 -Scope It + } + } + + Context "When dependencies have unknown package manager" { + It "Should use default color (DarkGray) for unknown managers" { + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + configuration = @{ + description = "Test" + version = "1.0" + createdBy = "Test" + createdDate = "2023-01-01" + lastUpdatedDate = "2023-01-02" + os = @{ name = "Windows" } + } + dependencies = @{ + unknownmanager = @{ + packages = @(@{ name = "unknown-package"; version = "1.0.0" }) + } + anotherUnknown = @{ + packages = @(@{ name = "another-package"; version = "2.0.0" }) + } + } + commands = @() + } + } + } + Mock Format-PrettyTable { param($Rows) + # Verify that unknown managers get DarkGray color + $unknownPackages = $Rows | Where-Object { $_.Provider -in @("unknownmanager", "anotherUnknown") } + if ($unknownPackages) { + foreach ($pkg in $unknownPackages) { + if ($pkg.Color -ne "DarkGray") { + throw "Expected unknown package manager to have DarkGray color, got $($pkg.Color)" + } + } + } + } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 2 -Scope It + } + } + + Context "When name is empty string" { + It "Should throw due to parameter validation" { + { Show-ExplainDevSetupEnv -Name "" } | Should -Throw + } + } + + Context "When path is empty string" { + It "Should throw due to parameter validation" { + { Show-ExplainDevSetupEnv -Path "" } | Should -Throw + } + } + + Context "When neither name nor path is provided" { + It "Should throw due to parameter set requirements" { + { Show-ExplainDevSetupEnv } | Should -Throw + } + } + + Context "When both name and path are provided" { + It "Should throw due to parameter set conflict" { + { Show-ExplainDevSetupEnv -Name "test" -Path "test.devsetup" } | Should -Throw + } + } + + Context "When Get-DevSetupEnvPath throws exception" { + It "Should handle gracefully" { + Mock Get-DevSetupEnvPath { throw "Path error" } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Read-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "When Test-Path throws exception" { + It "Should handle gracefully" { + Mock Test-Path { throw "Path test error" } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Read-DevSetupEnvFile -Exactly 0 -Scope It + } + } + + Context "When Format-PrettyTable throws exception" { + It "Should continue execution" { + Mock Format-PrettyTable { throw "Table format error" } + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 1 -Scope It + } + } + + Context "When second Format-PrettyTable throws exception" { + BeforeEach { + $script:callCount = 0; + Mock Format-PrettyTable { + switch ($script:callCount) { + 0 { + $script:callCount++ + return $true + } + 1 { + throw "Table format error" + } + default { + return + } + } + } + } + It "Should continue execution" { + Show-ExplainDevSetupEnv -Name "testenv" + Assert-MockCalled Format-PrettyTable -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to format" -and $Verbosity -eq "Error" } + } + } +} diff --git a/DevSetup/Private/Commands/Show-ExplainDevSetupEnv.ps1 b/DevSetup/Private/Commands/Show-ExplainDevSetupEnv.ps1 new file mode 100644 index 0000000..67e210f --- /dev/null +++ b/DevSetup/Private/Commands/Show-ExplainDevSetupEnv.ps1 @@ -0,0 +1,157 @@ +Function Show-ExplainDevSetupEnv { + [CmdletBinding()] + [OutputType([void])] + Param( + [Parameter(Mandatory = $true, ParameterSetName = "Explain")] + [string]$Name, + [Parameter(Mandatory = $true, ParameterSetName = "ExplainPath")] + [string]$Path + ) + + $YamlFile = $null + + if($PSBoundParameters.ContainsKey('Name')) { + $Provider = "local" + + if($Name -like "*:*") { + $parts = $Name.Split(":") + $Name = $parts[1]; + $Provider = $parts[0] + } + + try { + $envPath = Get-DevSetupEnvPath -Provider $Provider + } catch { + Write-StatusMessage "Failed to get environment path. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + $YamlFile = Join-Path -Path (Join-Path -Path $envPath -ChildPath $Provider) -ChildPath "$Name.devsetup" + } elseif($PSBoundParameters.ContainsKey('Path')) { + if(-not (Test-Path -Path $Path)) { + Write-StatusMessage "Invalid Path provided" -Verbosity Error + return + } + $YamlFile = $Path + $Name = (Split-Path -Path $YamlFile -Leaf).Replace(".devsetup","") + $Provider = "Path" + } + + Write-StatusMessage "Reading environment file: $YamlFile" -ForegroundColor Gray + try { + if (-not (Test-Path $YamlFile)) { + Write-StatusMessage "Environment file not found: $YamlFile" -Verbosity Error + return + } + } catch { + Write-StatusMessage "Failed to access environment file: $YamlFile. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + + try { + $YamlData = Read-DevSetupEnvFile -Config $YamlFile + if (-not $YamlData) { + Write-StatusMessage "Failed to read or parse environment file: $YamlFile" -Verbosity Error + return + } + } catch { + Write-StatusMessage "Failed to read or parse environment file: $YamlFile. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + + if ((-not $YamlData.devsetup) -or (-not $YamlData.devsetup.configuration) -or (-not $YamlData.devsetup.dependencies)) { + Write-StatusMessage "Malformed devsetup environment file: $YamlFile" -Verbosity Warning + return + } + + $overviewTableFormat = @{ + BorderColor = "DarkGray" + NoHeader = $true + } + + $overviewData = @( + @{ Name = "Name:"; Value = $Name; Color = "DarkCyan" } + @{ Name = "Provider:"; Value = $Provider; Color = "DarkCyan" } + @{ Name = "Description:"; Value = $YamlData.devsetup.configuration.description; Color = "DarkCyan" } + @{ Name = "Version:"; Value = $YamlData.devsetup.configuration.version; Color = "DarkCyan" } + @{ Name = "Created By:"; Value = $YamlData.devsetup.configuration.createdBy; Color = "DarkCyan" } + @{ Name = "Created Date:"; Value = $YamlData.devsetup.configuration.createdDate; Color = "DarkCyan" } + @{ Name = "Last Updated:"; Value = $YamlData.devsetup.configuration.lastUpdatedDate; Color = "DarkCyan" } + @{ Name = "Operating System:"; Value = $YamlData.devsetup.configuration.os.name; Color = "DarkCyan" } + @{ Name = "Packages:"; Value = ($YamlData.devsetup.dependencies | Foreach-Object { $_[$_.Keys].packages.Count } | Measure-Object -Sum).Sum; Color = "DarkCyan" } + @{ Name = "Modules:"; Value = ($YamlData.devsetup.dependencies | Foreach-Object { $_[$_.Keys].modules.Count } | Measure-Object -Sum).Sum; Color = "DarkCyan" } + @{ Name = "Commands:"; Value = $YamlData.devsetup.commands.Count; Color = "DarkCyan" } + ) + $overviewColumns = [ordered]@{ + Name = @{ Name = "Name"; Width = 30; Alignment = "Right"; Color = "White"; Key = "Name" } + Value = @{ Name = "Value"; Width = 87; Alignment = "Left"; Color = "White"; Key = "Value" } + } + + try { + Format-PrettyTable -Rows $overviewData -Columns $overviewColumns -TableFormat $overviewTableFormat + } catch { + Write-StatusMessage "Failed to format overview table: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + + Write-StatusMessage "`nThis environment installs the following packages and modules:" -ForegroundColor Gray + + $tableFormat = @{ + BorderColor = "DarkGray" + } + + $tableData = @() + $columnDefinitions = [ordered]@{ + Name = @{ Name = "Name"; Width = 81; Alignment = "Left"; Color = "White"; Key = "Name" } + Version = @{ Name = "Version"; Width = 15; Alignment = "Center"; Color = "White"; Key = "Version" } + Provider = @{ Name = "Provider"; Width = 20; Alignment = "Center"; Color = "White"; Key = "Provider" } + } + + $YamlData.devsetup.dependencies.GetEnumerator() | ForEach-Object { + $manager = $_.Key + $packages = $_.Value.packages + $modules = $_.Value.modules + $color = "DarkGray" + if ($packages -and $packages.Count -gt 0) { + switch ($manager) { + "chocolatey" { $color = "DarkCyan" } + "scoop" { $color = "DarkMagenta" } + "homebrew" { $color = "DarkYellow" } + default { $color = "DarkGray" } + } + foreach ($package in $packages) { + $tableData += @{ + Name = $package.name + Version = $package.version + Provider = $manager + Color = $color + } + } + } + if ($modules -and $modules.Count -gt 0) { + foreach ($module in $modules) { + $tableData += @{ + Name = $module.name + Version = $module.minimumVersion + Provider = $manager + Color = "DarkBlue" + } + } + } + } + if( $tableData.Count -eq 0 ) { + Write-StatusMessage "No packages or modules defined in this environment." -ForegroundColor Yellow + return + } + + try { + Format-PrettyTable -Rows $tableData -Columns $columnDefinitions -TableFormat $tableFormat + } catch { + Write-StatusMessage "Failed to format table: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } +} \ No newline at end of file diff --git a/DevSetup/Private/Commands/Uninstall-DevSetupEnv.Tests.ps1 b/DevSetup/Private/Commands/Uninstall-DevSetupEnv.Tests.ps1 index 478e95c..20a3d46 100644 --- a/DevSetup/Private/Commands/Uninstall-DevSetupEnv.Tests.ps1 +++ b/DevSetup/Private/Commands/Uninstall-DevSetupEnv.Tests.ps1 @@ -1,26 +1,26 @@ BeforeAll { Function Write-EZLog { } . (Join-Path $PSScriptRoot "Uninstall-DevSetupEnv.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Read-ConfigurationFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Read-DevSetupEnvFile.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Get-DevSetupEnvPath.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Scoop\Uninstall-ScoopComponents.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Chocolatey\Uninstall-ChocolateyPackages.ps1") - . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Powershell\Uninstall-PowershellModules.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Scoop\Invoke-ScoopComponentUninstall.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Chocolatey\Invoke-ChocolateyPackageUninstall.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Powershell\Invoke-PowershellModulesUninstall.ps1") . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Homebrew\Invoke-HomebrewComponentsUninstall.ps1") Mock Get-DevSetupEnvPath { "$TestDrive\DevSetup\DevSetupEnvs" } Mock Test-Path { $true } - Mock Read-ConfigurationFile { } - Mock Uninstall-PowershellModules { Param($YamlData, $DryRun) $true } - Mock Uninstall-ChocolateyPackages { Param($YamlData, $DryRun) $true } - Mock Uninstall-ScoopComponents { Param($YamlData, $DryRun) $true } - Mock Test-OperatingSystem { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Invoke-PowershellModulesUninstall { Param($YamlData, $DryRun) $true } + Mock Invoke-ChocolateyPackageUninstall { Param($YamlData, $DryRun) $true } + Mock Invoke-ScoopComponentUninstall { Param($YamlData, $DryRun) $true } + Mock Test-OperatingSystem { Param($Windows, $Linux, $MacOS) { return $true } } Mock Write-Host { } Mock Write-Error { } Mock Write-StatusMessage { } Mock Write-EZLog { } - Mock Invoke-HomebrewComponentsUninstall { $true } + Mock Invoke-HomebrewComponentsUninstall { Param($YamlData, $DryRun) $true } } Describe "Uninstall-DevSetupEnv" { @@ -28,7 +28,7 @@ Describe "Uninstall-DevSetupEnv" { Context "When environment file does not exist" { It "Should write error and return" { Mock Test-Path { $false } - $result = Uninstall-DevSetupEnv -Name "missing-env" -DryRun:$false + $result = Uninstall-DevSetupEnv -Name "missing-env" $result | Should -Be $null Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Environment file not found" -and $Verbosity -eq "Error" } } @@ -37,59 +37,180 @@ Describe "Uninstall-DevSetupEnv" { Context "When YAML parsing fails" { It "Should write error and return" { Mock Test-Path { $true } - Mock Read-ConfigurationFile { $null } + Mock Read-DevSetupEnvFile { $null } $result = Uninstall-DevSetupEnv -Name "bad-yaml" $result | Should -Be $null Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to parse YAML" -and $Verbosity -eq "Error" } } } - Context "When all uninstallers succeed" { - It "Should call all uninstallers and write status" { + Context "When all uninstallers succeed on Windows" { + It "Should call all Windows uninstallers and write status" { Mock Test-Path { $true } - Mock Read-ConfigurationFile { @{ devsetup = @{ } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } Mock Test-OperatingSystem { Param($Windows, $Linux, $MacOS) { return $true } } $result = Uninstall-DevSetupEnv -Name "basic-env" $result | Should -Be $null - Assert-MockCalled Test-OperatingSystem -Exactly 1 -Scope It - Assert-MockCalled Uninstall-PowershellModules -Exactly 1 -Scope It - Assert-MockCalled Uninstall-ChocolateyPackages -Exactly 1 -Scope It - Assert-MockCalled Uninstall-ScoopComponents -Exactly 1 -Scope It + Assert-MockCalled Test-OperatingSystem -Exactly 1 -Scope It -ParameterFilter { $Windows -eq $true } + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentUninstall -Exactly 1 -Scope It Assert-MockCalled Invoke-HomebrewComponentsUninstall -Exactly 0 -Scope It Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Uninstalling DevSetup environment from:" } } } + Context "When all uninstallers succeed on non-Windows" { + It "Should call Homebrew uninstaller and write status" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $false } + $result = Uninstall-DevSetupEnv -Name "basic-env" + $result | Should -Be $null + Assert-MockCalled Test-OperatingSystem -Exactly 1 -Scope It -ParameterFilter { $Windows -eq $true } + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 0 -Scope It + Assert-MockCalled Invoke-ScoopComponentUninstall -Exactly 0 -Scope It + Assert-MockCalled Invoke-HomebrewComponentsUninstall -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Uninstalling DevSetup environment from:" } + } + } + Context "When a component uninstaller fails" { It "Should continue calling other uninstallers" { Mock Test-OperatingSystem { return $true } $script:callCount = 0 - Mock Uninstall-PowershellModules { $script:callCount++; $false } - Mock Uninstall-ChocolateyPackages { $script:callCount++; $true } - Mock Uninstall-ScoopComponents { $script:callCount++; $true } + Mock Invoke-PowershellModulesUninstall { $script:callCount++; $false } + Mock Invoke-ChocolateyPackageUninstall { $script:callCount++; $true } + Mock Invoke-ScoopComponentUninstall { $script:callCount++; $true } Mock Invoke-HomebrewComponentsUninstall { $script:callCount++; $true } Mock Test-Path { $true } - Mock Read-ConfigurationFile { @{ devsetup = @{ } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } $result = Uninstall-DevSetupEnv -Name "partial-fail" Assert-MockCalled Test-OperatingSystem -Exactly 1 -Scope It -ParameterFilter { $Windows -eq $true } - Assert-MockCalled Uninstall-PowershellModules -Exactly 1 -Scope It - Assert-MockCalled Uninstall-ChocolateyPackages -Exactly 1 -Scope It - Assert-MockCalled Uninstall-ScoopComponents -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentUninstall -Exactly 1 -Scope It Assert-MockCalled Invoke-HomebrewComponentsUninstall -Exactly 0 -Scope It $result | Should -Be $null $script:callCount | Should -Be 3 - } } Context "When an exception occurs during uninstall" { It "Should write error and return" { Mock Test-Path { $true } - Mock Read-ConfigurationFile { throw "Unexpected error" } + Mock Read-DevSetupEnvFile { throw "Unexpected error" } $result = Uninstall-DevSetupEnv -Name "exception-env" $result | Should -Be $null Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Verbosity -eq "Error" } } } + + Context "When using Path parameter with valid path" { + It "Should use the provided path and uninstall" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $true } + $result = Uninstall-DevSetupEnv -Path "$TestDrive\valid.yaml" + $result | Should -Be $null + Assert-MockCalled Test-Path -Exactly 2 -Scope It -ParameterFilter { $Path -eq "$TestDrive\valid.yaml" } + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentUninstall -Exactly 1 -Scope It + } + } + + Context "When using Path parameter with invalid path" { + It "Should write error and return" { + Mock Test-Path { $false } + $result = Uninstall-DevSetupEnv -Path "$TestDrive\invalid.yaml" + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -match "Invalid Path provided" } + } + } + + Context "When Name includes provider" { + It "Should parse provider and name correctly" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $true } + $result = Uninstall-DevSetupEnv -Name "custom:MyEnv" + $result | Should -Be $null + Assert-MockCalled Get-DevSetupEnvPath -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It + } + } + + Context "When Name does not include provider" { + It "Should default to local provider" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $true } + $result = Uninstall-DevSetupEnv -Name "MyEnv" + $result | Should -Be $null + Assert-MockCalled Get-DevSetupEnvPath -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It + } + } + + Context "When DryRun is specified on Windows" { + It "Should pass DryRun to uninstallers" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $true } + $result = Uninstall-DevSetupEnv -Name "dry-run-env" -DryRun + $result | Should -Be $null + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + #Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 1 -Scope It -ParameterFilter { $WhatIf -eq $true } + #Assert-MockCalled Invoke-ScoopComponentUninstall -Exactly 1 -Scope It -ParameterFilter { $WhatIf -eq $true } + Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentUninstall -Exactly 1 -Scope It + Assert-MockCalled Invoke-HomebrewComponentsUninstall -Exactly 0 -Scope It + } + } + + Context "When DryRun is specified on non-Windows" { + It "Should pass DryRun to Homebrew uninstaller" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $false } + $result = Uninstall-DevSetupEnv -Name "dry-run-env" -DryRun + $result | Should -Be $null + Assert-MockCalled Invoke-PowershellModulesUninstall -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 0 -Scope It + Assert-MockCalled Invoke-ScoopComponentUninstall -Exactly 0 -Scope It + Assert-MockCalled Invoke-HomebrewComponentsUninstall -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $true } + $result = Uninstall-DevSetupEnv -Name "win-env" + $result | Should -Be $null + Assert-MockCalled Invoke-ChocolateyPackageUninstall -Exactly 1 -Scope It + } + + It "Should work on Linux" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $false } + $result = Uninstall-DevSetupEnv -Name "linux-env" + $result | Should -Be $null + Assert-MockCalled Invoke-HomebrewComponentsUninstall -Exactly 1 -Scope It + } + + It "Should work on macOS" { + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ } } } + Mock Test-OperatingSystem { return $false } + $result = Uninstall-DevSetupEnv -Name "mac-env" + $result | Should -Be $null + Assert-MockCalled Invoke-HomebrewComponentsUninstall -Exactly 1 -Scope It + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 b/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 index 696833b..a149d91 100644 --- a/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 +++ b/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 @@ -40,8 +40,8 @@ - Validates YAML file existence before attempting to parse configuration - Processes uninstallation in specific order: 1. PowerShell modules via Uninstall-PowershellModules - 2. Chocolatey packages via Uninstall-ChocolateyPackages - 3. Scoop packages via Uninstall-ScoopComponents + 2. Chocolatey packages via Invoke-ChocolateyPackageUninstall + 3. Scoop packages via Invoke-ScoopComponentUninstall - Each uninstaller function handles its own error reporting and validation - Does not remove the YAML configuration file itself after uninstallation - Provides descriptive error messages for missing or invalid configuration files @@ -102,7 +102,7 @@ Function Uninstall-DevSetupEnv { Write-StatusMessage "- $YamlFile`n" -Indent 2 -ForegroundColor Gray # Read the configuration from the YAML file - $YamlData = Read-ConfigurationFile -Config $YamlFile + $YamlData = Read-DevSetupEnvFile -Config $YamlFile # Check if YAML data was successfully parsed if ($null -eq $YamlData) { @@ -111,16 +111,16 @@ Function Uninstall-DevSetupEnv { } # Uninstall PowerShell module dependencies - Uninstall-PowershellModules -YamlData $YamlData | Out-Null + Invoke-PowershellModulesUninstall -YamlData $YamlData -DryRun:$DryRun | Out-Null $windows = Test-OperatingSystem -Windows if ($windows) { # Uninstall Chocolatey package dependencies - Uninstall-ChocolateyPackages -YamlData $YamlData | Out-Null + Invoke-ChocolateyPackageUninstall -YamlData $YamlData -DryRun:$DryRun | Out-Null # Uninstall Scoop package dependencies - Uninstall-ScoopComponents -YamlData $YamlData | Out-Null + Invoke-ScoopComponentUninstall -YamlData $YamlData -DryRun:$DryRun | Out-Null } else { # Uninstall Homebrew package dependencies Invoke-HomebrewComponentsUninstall -YamlData $YamlData -DryRun:$DryRun | Out-Null diff --git a/DevSetup/Private/Commands/Update-DevSetup.Tests.ps1 b/DevSetup/Private/Commands/Update-DevSetup.Tests.ps1 new file mode 100644 index 0000000..fb739e4 --- /dev/null +++ b/DevSetup/Private/Commands/Update-DevSetup.Tests.ps1 @@ -0,0 +1,88 @@ +BeforeAll { + # Source the function under test + . $PSScriptRoot\Update-DevSetup.ps1 + . $PSScriptRoot\..\Updater\Start-DevSetupSelfUpdate.ps1 + Mock Start-DevSetupSelfUpdate { } +} + +Describe "Update-DevSetup" { + + Context "When Main parameter is specified" { + It "Should call Start-DevSetupSelfUpdate with Main parameter" { + Update-DevSetup -Main + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $Main -eq $true } + } + } + + Context "When Develop parameter is specified" { + It "Should call Start-DevSetupSelfUpdate with Develop parameter" { + Update-DevSetup -Develop + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $Develop -eq $true } + } + } + + Context "When Version parameter is specified" { + It "Should call Start-DevSetupSelfUpdate with Version parameter" { + Update-DevSetup -Version "1.0.0" + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $Version -eq "1.0.0" } + } + } + + Context "When no parameters are specified (default)" { + It "Should call Start-DevSetupSelfUpdate without parameters (letting it use its own defaults)" { + Update-DevSetup + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $PSBoundParameters.Count -eq 0 } + } + } + + Context "Parameter set validation" { + It "Should allow Main parameter alone" { + { Update-DevSetup -Main } | Should -Not -Throw + } + + It "Should allow Develop parameter alone" { + { Update-DevSetup -Develop } | Should -Not -Throw + } + + It "Should allow Version parameter alone" { + { Update-DevSetup -Version "2.0.0" } | Should -Not -Throw + } + + It "Should not allow Main and Develop together" { + { Update-DevSetup -Main -Develop } | Should -Throw + } + + It "Should not allow Main and Version together" { + { Update-DevSetup -Main -Version "1.0.0" } | Should -Throw + } + + It "Should not allow Develop and Version together" { + { Update-DevSetup -Develop -Version "1.0.0" } | Should -Throw + } + } + + Context "PSBoundParameters forwarding" { + It "Should forward all parameters using splatting" { + Mock Start-DevSetupSelfUpdate { } -ParameterFilter { $PSBoundParameters.Count -gt 0 } + Update-DevSetup -Version "test" + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Update-DevSetup -Main + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + + It "Should work on Linux" { + Update-DevSetup -Develop + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + + It "Should work on macOS" { + Update-DevSetup -Version "1.0.0" + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Commands/Update-DevSetup.ps1 b/DevSetup/Private/Commands/Update-DevSetup.ps1 index d52ae68..c6fa15d 100644 --- a/DevSetup/Private/Commands/Update-DevSetup.ps1 +++ b/DevSetup/Private/Commands/Update-DevSetup.ps1 @@ -1,30 +1,13 @@ Function Update-DevSetup { - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName="ReleaseInstall")] Param( - [Parameter(Mandatory=$true, ParameterSetName="Main")] + [Parameter(Mandatory=$true, ParameterSetName="MainWebInstall")] [switch]$Main, - [Parameter(Mandatory=$true, ParameterSetName="Develop")] + [Parameter(Mandatory=$true, ParameterSetName="DevelopWebInstall")] [switch]$Develop, - [Parameter(Mandatory=$true, ParameterSetName="Version")] - [string]$Version, - [Parameter(Mandatory=$true, ParameterSetName="Latest")] - [switch]$Latest + [Parameter(Mandatory=$false, ParameterSetName="ReleaseInstall")] + [string]$Version = "latest" ) - $RemoteVersion = Get-DevSetupVersion -Remote - $LocalVersion = Get-DevSetupVersion -Local - if($RemoteVersion -gt $LocalVersion) { - Write-Host "A new version of DevSetup is available: $RemoteVersion (current version: $LocalVersion)" -ForegroundColor Yellow - } elseif ($RemoteVersion -eq $LocalVersion) { - Write-Host "You are already running the latest version of DevSetup: $LocalVersion" -ForegroundColor Green - return - } else { - Write-Host "You are running a newer version of DevSetup ($LocalVersion) than the latest release ($RemoteVersion)" -ForegroundColor Yellow - return - } - Write-Host "" - Write-Host "- Updating list of available environments..." -ForegroundColor Cyan - Optimize-DevSetupEnvs | Out-Null - Write-Host "- Available environments updated successfully" -ForegroundColor Green - Write-Host "" + Start-DevSetupSelfUpdate @PSBoundParameters } \ No newline at end of file diff --git a/DevSetup/Private/Enums/TaskState.ps1 b/DevSetup/Private/Enums/TaskState.ps1 deleted file mode 100644 index f3a787a..0000000 --- a/DevSetup/Private/Enums/TaskState.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -Add-Type -ErrorAction SilentlyContinue -IgnoreWarnings -Language CSharp -TypeDefinition @" - [System.FlagsAttribute] - public enum TaskState { - Unknown = 0, - Pass = 1 << 0, - Warn = 1 << 1, - Fail = 1 << 2, - } -"@ \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Export-InstalledChocolateyPackages.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Export-InstalledChocolateyPackages.Tests.ps1 deleted file mode 100644 index 0c0cf58..0000000 --- a/DevSetup/Private/Providers/Chocolatey/Export-InstalledChocolateyPackages.Tests.ps1 +++ /dev/null @@ -1,102 +0,0 @@ -BeforeAll { - function ConvertTo-Yaml { } - . $PSScriptRoot\Export-InstalledChocolateyPackages.ps1 - . $PSScriptRoot\Get-ChocolateyPackageDependencies.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Read-ConfigurationFile.ps1 - Mock Test-RunningAsAdmin { $true } - Mock Get-ChocolateyPackageDependencies { @('chocolatey-core.extension') } - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } - Mock ConvertTo-Yaml { param($obj) "yaml-output" } - Mock ConvertTo-Json { param($obj) "json-output" } - Mock Out-File { $true } - Mock Write-Host { } - Mock Write-Warning { } - Mock Write-Error { } - Mock Write-Debug { } - Mock Write-Verbose { } -} - -Describe "Export-InstalledChocolateyPackages" { - - Context "When not running as administrator" { - It "Should throw and return false" { - Mock Test-RunningAsAdmin { $false } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Error -Scope It - } - } - - Context "When no Chocolatey packages are found" { - It "Should warn and return true" { - Mock Test-RunningAsAdmin { $true } - Mock Invoke-Expression { @() } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "No Chocolatey packages found" } - } - } - - Context "When Chocolatey packages are found and DryRun is used" { - It "Should display the YAML output and not write to file" { - Mock Invoke-Expression { @("git|2.40.0", "nodejs|18.16.0") } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" -DryRun - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Yaml -Scope It - Assert-MockCalled Out-File -Times 0 -Scope It - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Dry Run" } - } - } - - Context "When Chocolatey packages are found and OutFile is specified" { - It "Should write the YAML output to the specified file" { - Mock Invoke-Expression { @("git|2.40.0", "nodejs|18.16.0") } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" -OutFile "out.yaml" - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Yaml -Scope It - Assert-MockCalled Out-File -Scope It -ParameterFilter { $FilePath -eq "out.yaml" } - } - } - - Context "When YAML conversion fails" { - It "Should fallback to JSON output" { - Mock Invoke-Expression { @("git|2.40.0") } - Mock ConvertTo-Yaml { throw "YAML error" } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" -DryRun - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Json -Scope It - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "Could not convert to YAML format" } - } - } - - Context "When Out-File fails" { - It "Should write error and return false" { - Mock Invoke-Expression { @("git|2.40.0") } - Mock Out-File { throw "File error" } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Failed to save configuration" } - } - } - - Context "When package version changes" { - It "Should update the package version in the config" { - Mock Invoke-Expression { @("git|2.41.0") } - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @(@{ name = "git"; version = "2.40.0" }) } } } } } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Updating package: git" } - } - } - - Context "When package is new" { - It "Should add the package to the config" { - Mock Invoke-Expression { @("newpkg|1.0.0") } - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } - $result = Export-InstalledChocolateyPackages -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Adding package: newpkg" } - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Export-InstalledChocolateyPackages.ps1 b/DevSetup/Private/Providers/Chocolatey/Export-InstalledChocolateyPackages.ps1 deleted file mode 100644 index 55dc2b4..0000000 --- a/DevSetup/Private/Providers/Chocolatey/Export-InstalledChocolateyPackages.ps1 +++ /dev/null @@ -1,234 +0,0 @@ -๏ปฟ<# -.SYNOPSIS - Exports installed Chocolatey packages to a YAML configuration file. - -.DESCRIPTION - This function scans the system for installed Chocolatey packages and exports them to a YAML - configuration file in DevSetup format. It uses 'choco list --local-only --limit-output' to retrieve - comprehensive package information including versions. The function intelligently filters out - system packages and can update existing configuration files by merging new packages with existing ones. - -.PARAMETER Config - The path to the YAML configuration file to read from and write to. - This parameter is mandatory and specifies both the input and output file unless OutFile is specified. - -.PARAMETER OutFile - The path to save the updated YAML configuration. - Optional parameter that allows saving to a different file than the input Config file. - -.PARAMETER DryRun - Switch parameter that prevents writing to files and displays the resulting configuration to the console. - Useful for previewing changes before committing them to a file. - -.OUTPUTS - [System.Boolean] - Returns $true if the export completes successfully or if no packages are found. - Returns $false if there are errors during the export process. - -.EXAMPLE - Export-InstalledChocolateyPackages -Config "environment.yaml" - - Exports installed Chocolatey packages to the existing environment.yaml configuration file. - -.EXAMPLE - Export-InstalledChocolateyPackages -Config "current.yaml" -OutFile "backup.yaml" - - Reads from current.yaml and saves the updated configuration with installed packages to backup.yaml. - -.EXAMPLE - Export-InstalledChocolateyPackages -Config "dev-env.yaml" -DryRun - - Shows what the configuration would look like without actually saving to file. - -.NOTES - - Requires administrator privileges to access all installed packages - - Uses 'choco list --local-only --limit-output' for machine-readable package information - - Automatically filters out system packages: - * Packages ending with '.install' (installer packages) - * Packages starting with 'chocolatey' (Chocolatey system packages) - - Merges with existing YAML configuration, preserving other sections and structure - - Supports both simple string format and complex object format for packages - - Updates existing packages when versions have changed - - Converts string entries to hashtable format when version information is added - - Creates the devsetup.dependencies.chocolatey structure if it doesn't exist - - Provides detailed console output with color-coded status messages for operations - - Handles YAML conversion errors gracefully by falling back to JSON format - - Tracks package changes: new additions, version updates, and no-change skips - -.LINK - -.COMPONENT - DevSetup.Providers.Chocolatey - -.FUNCTIONALITY - Configuration Export, Package Discovery, YAML Generation -#> - -Function Export-InstalledChocolateyPackages { - [CmdletBinding()] - Param( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string]$Config, - [Parameter(Mandatory = $false)] - [ValidateNotNullOrEmpty()] - [string]$OutFile, - [Parameter(Mandatory = $false)] - [switch]$DryRun - ) - - try { - # Check if running as administrator - if (-not (Test-RunningAsAdmin)) { - throw "This operation requires administrator privileges. Please run as administrator." - } - - # Get list of installed Chocolatey packages - Write-Host "- Getting list of installed Chocolatey packages..." -ForegroundColor Gray - $chocoList = Invoke-Expression "& choco list --local-only --limit-output" - - if (-not $chocoList) { - Write-Warning "No Chocolatey packages found or Chocolatey is not installed." - return $true - } - - $chocolateyPackages = @() - - $packagesToIgnore = Get-ChocolateyPackageDependencies | Select-Object -Unique - - foreach ($line in $chocoList) { - if ([string]::IsNullOrWhiteSpace($line)) { continue } - - # Parse package info (format: packagename|version) - $parts = $line.Split('|') - if ($parts.Count -ge 2) { - $packageName = $parts[0].Trim() - $version = $parts[1].Trim() - - # Skip packages starting with chocolatey - if ($packageName -like "chocolatey*") { - Write-Verbose "Skipping chocolatey package: $packageName" - continue - } - - if($packagesToIgnore -contains $packageName) { - Write-Verbose "Skipping ignored package: $packageName" - continue - } - - Write-Debug "Found package: $packageName (version: $version)" - $chocolateyPackages += @{ - name = $packageName - version = $version - } - } - } - - Write-Debug "Found $($chocolateyPackages.Count) Chocolatey packages (excluding .install and chocolatey* packages)" - - # Read existing YAML configuration - $YamlData = Read-ConfigurationFile -Config $Config - - # Ensure chocolateyPackages section exists - if (-not $YamlData.devsetup) { $YamlData.devsetup = @{} } - if (-not $YamlData.devsetup.dependencies) { $YamlData.devsetup.dependencies = @{} } - if (-not $YamlData.devsetup.dependencies.chocolatey) { $YamlData.devsetup.dependencies.chocolatey = @{} } - if (-not $YamlData.devsetup.dependencies.chocolatey.packages) { $YamlData.devsetup.dependencies.chocolatey.packages = @() } - - # Add packages to YAML data - foreach ($package in $chocolateyPackages) { - # Check if package already exists - $existingPackage = $YamlData.devsetup.dependencies.chocolatey.packages | Where-Object { - ($_ -is [string] -and $_ -eq $package.name) -or - ($_ -is [hashtable] -and $_.name -eq $package.name) - } - - if (-not $existingPackage) { - Write-Host " - Adding package: $($package.name) ($($package.version))" -ForegroundColor Gray - $YamlData.devsetup.dependencies.chocolatey.packages += @{ - name = $package.name - version = $package.version - } - } else { - # Package exists, check if version has changed - $existingVersion = $null - if ($existingPackage -is [hashtable] -and $existingPackage.version) { - $existingVersion = $existingPackage.version - } - - if ($existingVersion -and $existingVersion -ne $package.version) { - Write-Host " - Updating package: $($package.name) ($existingVersion -> $($package.version))" -ForegroundColor Cyan - - # Find index and update - $index = $YamlData.devsetup.dependencies.chocolatey.packages.IndexOf($existingPackage) - - # Preserve existing package structure but update version - if ($existingPackage -is [string]) { - # Convert string to hashtable with version - $YamlData.devsetup.dependencies.chocolatey.packages[$index] = @{ - name = $package.name - version = $package.version - } - } else { - # Update existing hashtable - $YamlData.devsetup.dependencies.chocolatey.packages[$index].version = $package.version - } - } elseif (-not $existingVersion) { - Write-Host " - Updating package: $($package.name)" -ForegroundColor Yellow - - # Find index and add version - $index = $YamlData.devsetup.dependencies.chocolatey.packages.IndexOf($existingPackage) - - if ($existingPackage -is [string]) { - # Convert string to hashtable with version - $YamlData.devsetup.dependencies.chocolatey.packages[$index] = @{ - name = $package.name - version = $package.version - } - } else { - # Add version to existing hashtable - $YamlData.devsetup.dependencies.chocolatey.packages[$index].version = $package.version - } - } else { - Write-Host " - Skipping package (No Change): $($package.name) ($($package.version))" -ForegroundColor Gray - } - } - } - - # Convert to YAML - try { - $yamlOutput = $YamlData | ConvertTo-Yaml - } - catch { - Write-Warning "Could not convert to YAML format. Showing PowerShell object instead:" - $yamlOutput = $YamlData | ConvertTo-Json -Depth 10 - } - - # Handle output based on parameters - if ($DryRun) { - Write-Host "`nDry Run - Configuration would be saved as:" -ForegroundColor Cyan - Write-Host $yamlOutput -ForegroundColor White - Write-Host "`nNo files were modified (dry run mode)." -ForegroundColor Yellow - } else { - # Determine output file - $outputFile = if ($OutFile) { $OutFile } else { $Config } - - try { - Write-Debug "`nSaving configuration to: $outputFile" - $yamlOutput | Out-File -FilePath $outputFile - Write-Debug "Configuration saved successfully!" - } - catch { - Write-Error "Failed to save configuration to $outputFile`: $_" - return $false - } - } - - Write-Host "Chocolatey packages conversion completed!" -ForegroundColor Green - return $true - } - catch { - Write-Error "Error converting Chocolatey packages: $_" - return $false - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Find-Chocolatey.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Find-Chocolatey.Tests.ps1 new file mode 100644 index 0000000..e84c521 --- /dev/null +++ b/DevSetup/Private/Providers/Chocolatey/Find-Chocolatey.Tests.ps1 @@ -0,0 +1,181 @@ +BeforeAll { + . $PSScriptRoot\Find-Chocolatey.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 +} + +Describe "Find-Chocolatey" { + + Context "When Chocolatey is found via Get-Command" { + It "Should return the path from Get-Command when choco is in PATH" { + $expectedPath = Join-Path $TestDrive "chocolatey" "bin" "choco.exe" + Mock Get-Command { + return @{ Path = $expectedPath } + } -ParameterFilter { $Name -eq "choco" } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -Be $expectedPath + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Found Chocolatey at: $([regex]::Escape($expectedPath))" -and $Verbosity -eq "Debug" + } + } + } + + Context "When Get-Command fails but ChocolateyInstall environment variable exists" { + It "Should return path from ChocolateyInstall environment variable" { + $chocolateyInstallPath = Join-Path $TestDrive "Chocolatey" + $expectedPath = Join-Path $chocolateyInstallPath "bin" "choco.exe" + + Mock Get-Command { return $null } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { + return $chocolateyInstallPath + } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $expectedPath } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -Be $expectedPath + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Found Chocolatey at: $([regex]::Escape($expectedPath))" -and $Verbosity -eq "Debug" + } + } + } + + Context "When ChocolateyInstall environment variable is not set" { + It "Should return null and log debug message" { + Mock Get-Command { return $null } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { return $null } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "ChocolateyInstall environment variable is not set." -and $Verbosity -eq "Debug" + } + } + } + + Context "When ChocolateyInstall path exists but choco.exe does not exist" { + It "Should return null and log debug message about missing executable" { + $chocolateyInstallPath = Join-Path $TestDrive "Chocolatey" + $expectedPath = Join-Path $chocolateyInstallPath "bin" "choco.exe" + + Mock Get-Command { return $null } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { + return $chocolateyInstallPath + } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $false } -ParameterFilter { $Path -eq $expectedPath } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "Chocolatey executable not found at expected path: $expectedPath" -and $Verbosity -eq "Debug" + } + } + } + + Context "When Get-Command throws an exception" { + It "Should handle Get-Command exception and continue with environment variable lookup" { + $chocolateyInstallPath = Join-Path $TestDrive "Chocolatey" + $expectedPath = Join-Path $chocolateyInstallPath "bin" "choco.exe" + + Mock Get-Command { throw "Command not found error" } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { + return $chocolateyInstallPath + } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $expectedPath } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -Be $expectedPath + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error finding Chocolatey command:" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Found Chocolatey at: $([regex]::Escape($expectedPath))" -and $Verbosity -eq "Debug" + } + } + } + + Context "When Get-EnvironmentVariable throws an exception" { + It "Should handle Get-EnvironmentVariable exception and return null" { + Mock Get-Command { return $null } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { throw "Environment variable access error" } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error retrieving ChocolateyInstall environment variable:" -and $Verbosity -eq "Error" + } + } + } + + Context "When Join-Path throws an exception" { + It "Should handle Join-Path exception and return null" { + $chocolateyInstallPath = "InvalidPath:" + + Mock Get-Command { return $null } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { + return $chocolateyInstallPath + } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Join-Path { throw "Invalid path error" } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error constructing Chocolatey path:" -and $Verbosity -eq "Error" + } + } + } + + Context "When all operations succeed via Get-Command" { + It "Should not attempt environment variable lookup when Get-Command succeeds" { + $expectedPath = Join-Path $TestDrive "chocolatey" "bin" "choco.exe" + Mock Get-Command { + return @{ Path = $expectedPath } + } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { throw "Should not be called" } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + $result | Should -Be $expectedPath + Assert-MockCalled Get-EnvironmentVariable -Times 0 -Scope It + } + } + + Context "Integration scenarios" { + It "Should return path when both methods would work but Get-Command takes precedence" { + $commandPath = Join-Path $TestDrive "system" "choco.exe" + $envInstallPath = Join-Path $TestDrive "custom" "chocolatey" + $envPath = Join-Path $envInstallPath "bin" "choco.exe" + + Mock Get-Command { + return @{ Path = $commandPath } + } -ParameterFilter { $Name -eq "choco" } + Mock Get-EnvironmentVariable { + return $envInstallPath + } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } + Mock Write-StatusMessage { } + + $result = Find-Chocolatey + + # Should return the Get-Command path, not the environment variable path + $result | Should -Be $commandPath + # Environment variable should not be called since Get-Command succeeded + Assert-MockCalled Get-EnvironmentVariable -Times 0 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Find-Chocolatey.ps1 b/DevSetup/Private/Providers/Chocolatey/Find-Chocolatey.ps1 new file mode 100644 index 0000000..017ad23 --- /dev/null +++ b/DevSetup/Private/Providers/Chocolatey/Find-Chocolatey.ps1 @@ -0,0 +1,46 @@ +Function Find-Chocolatey { + [CmdletBinding()] + [OutputType([string])] + Param( + ) + + # Check if Chocolatey is installed + try { + $Path = (Get-Command "choco" -ErrorAction SilentlyContinue).Path + } catch { + Write-StatusMessage "Error finding Chocolatey command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + } + + if ($Path) { + Write-StatusMessage "Found Chocolatey at: $Path" -Verbosity Debug + return $Path + } else { + try { + $ChocolateyInstallEnvPath = Get-EnvironmentVariable ChocolateyInstall + } catch { + Write-StatusMessage "Error retrieving ChocolateyInstall environment variable: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + if (-not $ChocolateyInstallEnvPath) { + Write-StatusMessage "ChocolateyInstall environment variable is not set." -Verbosity Debug + return $null + } else { + try { + $Path = Join-Path $ChocolateyInstallEnvPath "bin\choco.exe" + } catch { + Write-StatusMessage "Error constructing Chocolatey path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + if (Test-Path $Path) { + Write-StatusMessage "Found Chocolatey at: $Path" -Verbosity Debug + return $Path + } else { + Write-StatusMessage "Chocolatey executable not found at expected path: $Path" -Verbosity Debug + return $null + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.Tests.ps1 index d0a1feb..a4a86bf 100644 --- a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.Tests.ps1 @@ -1,51 +1,168 @@ BeforeAll { . $PSScriptRoot\Get-ChocolateyCacheFile.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-DevSetupCachePath.ps1 - Mock Write-Error { } } Describe "Get-ChocolateyCacheFile" { - Context "When Get-DevSetupCachePath returns a valid path" { + + Context "When Get-DevSetupCachePath succeeds" { It "Should return the correct cache file path" { - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - Mock Get-DevSetupCachePath { return "$TestDrive\Users\Test\devsetup\.cache" } - $result = Get-ChocolateyCacheFile - $result | Should -Be "$TestDrive\Users\Test\devsetup\.cache\chocolatey.cache" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - Mock Get-DevSetupCachePath { return "$TestDrive/home/testuser/devsetup/.cache" } - $result = Get-ChocolateyCacheFile - $result | Should -Be "$TestDrive/home/testuser/devsetup/.cache/chocolatey.cache" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - Mock Get-DevSetupCachePath { return "$TestDrive/Users/TestUser/devsetup/.cache" } - $result = Get-ChocolateyCacheFile - $result | Should -Be "$TestDrive/Users/TestUser/devsetup/.cache/chocolatey.cache" - } + $expectedCachePath = Join-Path $TestDrive ".cache" + $expectedCacheFile = Join-Path $expectedCachePath "chocolatey.cache" + + Mock Get-DevSetupCachePath { return $expectedCachePath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -Be $expectedCacheFile + Assert-MockCalled Get-DevSetupCachePath -Times 1 -Scope It } } - Context "When Get-DevSetupCachePath returns a different path" { - It "Should append chocolatey.cache to the returned path" { - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - Mock Get-DevSetupCachePath { return "$TestDrive\DevSetupCache" } - $result = Get-ChocolateyCacheFile - $result | Should -Be "$TestDrive\DevSetupCache\chocolatey.cache" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - Mock Get-DevSetupCachePath { return "$TestDrive/home/testuser/devsetupcache/.cache" } - $result = Get-ChocolateyCacheFile - $result | Should -Be "$TestDrive/home/testuser/devsetupcache/.cache/chocolatey.cache" - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - Mock Get-DevSetupCachePath { return "$TestDrive/Users/TestUser/devsetupcache/.cache" } - $result = Get-ChocolateyCacheFile - $result | Should -Be "$TestDrive/Users/TestUser/devsetupcache/.cache/chocolatey.cache" + Context "When Get-DevSetupCachePath returns null" { + It "Should return null and log error message" { + Mock Get-DevSetupCachePath { return $null } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "Failed to retrieve DevSetup cache path." -and $Verbosity -eq "Error" } } } - Context "When Get-DevSetupCachePath returns an empty string" { - It "Should write error and return null" { + Context "When Get-DevSetupCachePath returns empty string" { + It "Should return null and log error message" { Mock Get-DevSetupCachePath { return "" } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "Failed to retrieve DevSetup cache path." -and $Verbosity -eq "Error" + } + } + } + + Context "When Get-DevSetupCachePath returns whitespace string" { + It "Should return null and log error message" { + Mock Get-DevSetupCachePath { return " " } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "Failed to retrieve DevSetup cache path." -and $Verbosity -eq "Error" + } + } + } + + Context "When Get-DevSetupCachePath throws an exception" { + It "Should handle exception and return null" { + Mock Get-DevSetupCachePath { throw "Cache path access error" } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error retrieving DevSetup cache path:" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "at" + } + } + } + + Context "When Join-Path throws an exception" { + It "Should handle Join-Path exception and return null" { + $cachePath = "InvalidPath:" + + Mock Get-DevSetupCachePath { return $cachePath } + Mock Join-Path { throw "Invalid path error" } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error constructing Chocolatey cache file path:" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "at" + } + } + } + + Context "Path construction validation" { + It "Should correctly combine cache path and chocolatey.cache filename" { + $baseCachePath = Join-Path $TestDrive "custom" "cache" "directory" + $expectedResult = Join-Path $baseCachePath "chocolatey.cache" + + Mock Get-DevSetupCachePath { return $baseCachePath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -Be $expectedResult + $result | Should -Match "chocolatey\.cache$" + } + } + + Context "Cross-platform path handling" { + It "Should handle Unix-style paths correctly" { + $unixCachePath = Join-Path $TestDrive "home" "user" ".devsetup" ".cache" + $expectedResult = Join-Path $unixCachePath "chocolatey.cache" + + Mock Get-DevSetupCachePath { return $unixCachePath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -Be $expectedResult + $result.EndsWith("chocolatey.cache") | Should -BeTrue + } + + It "Should handle Windows-style paths correctly" { + $windowsCachePath = Join-Path $TestDrive "Users" "TestUser" "AppData" "Local" "DevSetup" "cache" + $expectedResult = Join-Path $windowsCachePath "chocolatey.cache" + + Mock Get-DevSetupCachePath { return $windowsCachePath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -Be $expectedResult + $result.EndsWith("chocolatey.cache") | Should -BeTrue + } + } + + Context "Return value validation" { + It "Should return a string type" { + $cachePath = Join-Path $TestDrive "cache" + + Mock Get-DevSetupCachePath { return $cachePath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyCacheFile + + $result | Should -BeOfType [string] + } + + It "Should return null (not empty string) on errors" { + Mock Get-DevSetupCachePath { throw "Error" } + Mock Write-StatusMessage { } + $result = Get-ChocolateyCacheFile - $result | Should -Be $null + + $result | Should -BeNullOrEmpty + $result | Should -BeExactly $null } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.ps1 b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.ps1 index 78472fb..b18b263 100644 --- a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyCacheFile.ps1 @@ -52,14 +52,26 @@ Function Get-ChocolateyCacheFile { Param() # Get the DevSetup cache path - $cachePath = Get-DevSetupCachePath - if([string]::IsNullOrEmpty($cachePath)) { - Write-Error "Failed to retrieve DevSetup cache path." + try { + $cachePath = Get-DevSetupCachePath + } catch { + Write-StatusMessage "Error retrieving DevSetup cache path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + if([string]::IsNullOrWhiteSpace($cachePath)) { + Write-StatusMessage "Failed to retrieve DevSetup cache path." -Verbosity Error return $null } # Construct the full path to the cache file - $cacheFilePath = Join-Path -Path $cachePath -ChildPath "chocolatey.cache" + try { + $cacheFilePath = Join-Path -Path $cachePath -ChildPath "chocolatey.cache" + } catch { + Write-StatusMessage "Error constructing Chocolatey cache file path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } return $cacheFilePath } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencies.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencies.Tests.ps1 deleted file mode 100644 index e5712e4..0000000 --- a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencies.Tests.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -BeforeAll { - . $PSScriptRoot\Get-ChocolateyPackageDependencies.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 - Mock Write-Debug { } - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - Mock Get-EnvironmentVariable { return "C:\choco" } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - Mock Get-EnvironmentVariable { return "/opt/choco" } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - Mock Get-EnvironmentVariable { return "/opt/choco" } - } -} - -Describe "Get-ChocolateyPackageDependencies" { - - Context "When Chocolatey install path does not exist" { - It "Should return $null in PS5, empty array in PS6+" { - Mock Test-Path { return $false } - $result = Get-ChocolateyPackageDependencies - $result | Should -Be $null - } - } - - Context "When no nuspec files are found" { - It "Should return $null in PS5, empty array in PS6+" { - Mock Test-Path { return $true } - Mock Get-ChildItem { @() } - $result = Get-ChocolateyPackageDependencies - $result | Should -Be $null - } - } - - Context "When nuspec files have no dependencies" { - It "Should return $null in PS5, empty array in PS6+" { - Mock Test-Path { return $true } - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "C:\choco\lib\foo\foo.nuspec" } - ) - } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "/opt/choco/lib/foo/foo.nuspec" } - ) - } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "/opt/choco/lib/foo/foo.nuspec" } - ) - } - } - Mock Get-Content { - '' - } - $result = Get-ChocolateyPackageDependencies - $result | Should -Be $null - } - } - - Context "When nuspec files have dependencies including chocolatey system packages" { - It "Should return only non-chocolatey dependencies" { - Mock Test-Path { return $true } - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "C:\choco\lib\foo\foo.nuspec" } - ) - } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "/opt/choco/lib/foo/foo.nuspec" } - ) - } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "/opt/choco/lib/foo/foo.nuspec" } - ) - } - } - Mock Get-Content { - ' - - - - ' - } - $result = Get-ChocolateyPackageDependencies - $result | Should -Not -Be $null - $result | Should -Contain "git" - $result | Should -Contain "nodejs" - $result | Should -Not -Contain "chocolatey-core.extension" - } - } - - Context "When multiple nuspec files have overlapping dependencies" { - It "Should return all dependencies including duplicates" { - Mock Test-Path { return $true } - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -ge 6 -and $IsWindows)) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "C:\choco\lib\foo\foo.nuspec" }, - [PSCustomObject]@{ FullName = "C:\choco\lib\bar\bar.nuspec" } - ) - } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "/opt/choco/lib/foo/foo.nuspec" }, - [PSCustomObject]@{ FullName = "/opt/choco/lib/bar/bar.nuspec" } - ) - } - } elseif ($PSVersionTable.PSVersion.Major -ge 6 -and $IsMacOS) { - Mock Get-ChildItem { - @( - [PSCustomObject]@{ FullName = "/opt/choco/lib/foo/foo.nuspec" }, - [PSCustomObject]@{ FullName = "/opt/choco/lib/bar/bar.nuspec" } - ) - } - } - $nuspecs = @( - ' - - - ', - ' - - - ' - ) - $script:callCount = 0 - Mock Get-Content -MockWith { - $nuspecs[$script:callCount++] - } - $result = Get-ChocolateyPackageDependencies - $result | Should -Not -Be $null - $result | Should -Contain "git" - $result | Should -Contain "nodejs" - $result | Should -Contain "python" - ($result | Where-Object { $_ -eq "nodejs" }).Count | Should -Be 2 - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencyMap.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencyMap.Tests.ps1 new file mode 100644 index 0000000..43aac27 --- /dev/null +++ b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencyMap.Tests.ps1 @@ -0,0 +1,314 @@ +BeforeAll { + . $PSScriptRoot\Get-ChocolateyPackageDependencyMap.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 +} + +Describe "Get-ChocolateyPackageDependencyMap" { + + Context "When Get-EnvironmentVariable succeeds and lib directory exists with dependencies" { + It "Should return all non-chocolatey dependencies from nuspec files" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + $nuspecPath1 = Join-Path $libPath "package1" "package1.nuspec" + $nuspecPath2 = Join-Path $libPath "package2" "package2.nuspec" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { + @( + [PSCustomObject]@{ FullName = $nuspecPath1 }, + [PSCustomObject]@{ FullName = $nuspecPath2 } + ) + } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + + $nuspecs = @( + ' + + + + ', + ' + + + ' + ) + $script:callCount = 0 + Mock Get-Content { $nuspecs[$script:callCount++] } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -Not -BeNullOrEmpty + $result | Should -Contain "git" + $result | Should -Contain "nodejs" + $result | Should -Contain "python" + $result | Should -Not -Contain "chocolatey-core.extension" + $result | Should -Not -Contain "chocolatey-windowsupdate.extension" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "Retrieving Chocolatey package dependencies..." -and $Verbosity -eq "Debug" + } + } + } + + Context "When Get-EnvironmentVariable throws an exception" { + It "Should handle exception and return null" { + Mock Get-EnvironmentVariable { throw "Environment variable access error" } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error retrieving ChocolateyInstall environment variable:" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "at" + } + } + } + + Context "When Join-Path throws an exception" { + It "Should handle Join-Path exception and return null" { + $chocolateyInstallPath = "InvalidPath:" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Join-Path { throw "Invalid path error" } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error constructing Chocolatey lib path:" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "at" + } + } + } + + Context "When Test-Path throws an exception" { + It "Should handle Test-Path exception and return null" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { throw "Path access error" } -ParameterFilter { $Path -eq $libPath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error testing Chocolatey lib path:" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "at" + } + } + } + + Context "When Chocolatey lib path does not exist" { + It "Should return null and log debug message" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $false } -ParameterFilter { $Path -eq $libPath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "Chocolatey installation path not found: $libPath" -and $Verbosity -eq "Debug" + } + } + } + + Context "When no nuspec files are found" { + It "Should return null" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { @() } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + } + } + + Context "When nuspec files have no dependencies section" { + It "Should return null" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + $nuspecPath = Join-Path $libPath "package1" "package1.nuspec" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { + @([PSCustomObject]@{ FullName = $nuspecPath }) + } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + Mock Get-Content { '' } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + } + } + + Context "When nuspec files have empty dependencies section" { + It "Should return null" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + $nuspecPath = Join-Path $libPath "package1" "package1.nuspec" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { + @([PSCustomObject]@{ FullName = $nuspecPath }) + } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + Mock Get-Content { '' } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + } + } + + Context "When nuspec files contain only chocolatey dependencies" { + It "Should return null after filtering" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + $nuspecPath = Join-Path $libPath "package1" "package1.nuspec" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { + @([PSCustomObject]@{ FullName = $nuspecPath }) + } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + Mock Get-Content { + ' + + + + ' + } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + } + } + + Context "When processing nuspec files throws an exception" { + It "Should handle processing exception and return null" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { throw "File access error" } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error processing nuspec files:" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "at" + } + } + } + + Context "When multiple packages have overlapping dependencies" { + It "Should return all dependencies including duplicates" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + $nuspecPath1 = Join-Path $libPath "package1" "package1.nuspec" + $nuspecPath2 = Join-Path $libPath "package2" "package2.nuspec" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { + @( + [PSCustomObject]@{ FullName = $nuspecPath1 }, + [PSCustomObject]@{ FullName = $nuspecPath2 } + ) + } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + + $nuspecs = @( + ' + + + ', + ' + + + ' + ) + $script:callCount = 0 + Mock Get-Content { $nuspecs[$script:callCount++] } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -Not -BeNullOrEmpty + $result | Should -Contain "git" + $result | Should -Contain "nodejs" + $result | Should -Contain "python" + # Should have duplicates + ($result | Where-Object { $_ -eq "nodejs" }).Count | Should -Be 2 + } + } + + Context "Return value validation" { + It "Should return null when no dependencies found" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $false } -ParameterFilter { $Path -eq $libPath } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -BeNullOrEmpty + } + + It "Should return dependencies when found" { + $chocolateyInstallPath = Join-Path $TestDrive "chocolatey" + $libPath = Join-Path $chocolateyInstallPath "lib" + $nuspecPath = Join-Path $libPath "package1" "package1.nuspec" + + Mock Get-EnvironmentVariable { return $chocolateyInstallPath } -ParameterFilter { $Name -eq "ChocolateyInstall" } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $libPath } + Mock Get-ChildItem { + @([PSCustomObject]@{ FullName = $nuspecPath }) + } -ParameterFilter { $Path -eq $libPath -and $Recurse -eq $true -and $Filter -eq "*.nuspec" } + Mock Get-Content { + ' + + + ' + } + Mock Write-StatusMessage { } + + $result = Get-ChocolateyPackageDependencyMap + + $result | Should -Not -BeNullOrEmpty + @($result) | Should -Contain "git" + @($result) | Should -Contain "nodejs" + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencies.ps1 b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencyMap.ps1 similarity index 59% rename from DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencies.ps1 rename to DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencyMap.ps1 index f0b8e03..c35910e 100644 --- a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencies.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyPackageDependencyMap.ps1 @@ -54,30 +54,56 @@ Dependency Analysis, Package Management, Metadata Extraction #> -Function Get-ChocolateyPackageDependencies { +Function Get-ChocolateyPackageDependencyMap { [CmdletBinding()] [OutputType([array])] Param() - write-Debug "Retrieving Chocolatey package dependencies..." + Write-StatusMessage "Retrieving Chocolatey package dependencies..." -Verbosity Debug $packageDependencies = @() - $chocolateyInstallPath = Join-Path (Get-EnvironmentVariable ChocolateyInstall) lib - if (-not (Test-Path $chocolateyInstallPath)) { - Write-Debug "Chocolatey installation path not found: $chocolateyInstallPath" - return $packageDependencies + try { + $ChocolateyInstallEnvPath = Get-EnvironmentVariable ChocolateyInstall + } catch { + Write-StatusMessage "Error retrieving ChocolateyInstall environment variable: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null } - Get-ChildItem $chocolateyInstallPath -Recurse "*.nuspec" | ForEach-Object { - $dependencies = ([xml](Get-Content $_.FullName)).package.metadata.dependencies.dependency | ForEach-Object { - if (-not ($_.id -like "chocolatey*")) { - $_.id - } + try { + $chocolateyInstallPath = Join-Path $ChocolateyInstallEnvPath lib + } catch { + Write-StatusMessage "Error constructing Chocolatey lib path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + if (-not (Test-Path $chocolateyInstallPath)) { + Write-StatusMessage "Chocolatey installation path not found: $chocolateyInstallPath" -Verbosity Debug + return $null } + } catch { + Write-StatusMessage "Error testing Chocolatey lib path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } - if ($dependencies) { - $packageDependencies = $packageDependencies + $dependencies; + try { + $packageDependencies = Get-ChildItem $chocolateyInstallPath -Recurse "*.nuspec" | ForEach-Object { + ([xml](Get-Content $_.FullName)).package.metadata.dependencies.dependency | ForEach-Object { + if (-not ($_.id -like "chocolatey*")) { + $_.id + } + } + } + if(-not $packageDependencies) { + return $null } + } catch { + Write-StatusMessage "Error processing nuspec files: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null } return [array]$packageDependencies } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.Tests.ps1 index 7494a1f..eca97a5 100644 --- a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.Tests.ps1 @@ -1,46 +1,491 @@ BeforeAll { . $PSScriptRoot\Get-ChocolateyVersion.ps1 + . $PSScriptRoot\..\..\Utils\Write-StatusMessage.ps1 . $PSScriptRoot\Test-ChocolateyInstalled.ps1 - Mock Write-Warning { } + . $PSScriptRoot\Find-Chocolatey.ps1 } Describe "Get-ChocolateyVersion" { - Context "When Chocolatey is not installed" { - It "Should return null and write a warning" { + It "Should return null and log warning when Test-ChocolateyInstalled returns false" { + # Arrange Mock Test-ChocolateyInstalled { return $false } + Mock Write-StatusMessage { } + Mock Find-Chocolatey { } + Mock Invoke-Command { } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Chocolatey is not installed. Cannot retrieve version." -and $Verbosity -eq "Warning" + } + Assert-MockCalled Find-Chocolatey -Exactly 0 -Scope It + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + + It "Should return null and log error when Test-ChocolateyInstalled throws exception" { + # Arrange + Mock Test-ChocolateyInstalled { throw "Test error from Test-ChocolateyInstalled" } + Mock Write-StatusMessage { } + Mock Find-Chocolatey { } + Mock Invoke-Command { } + + # Act $result = Get-ChocolateyVersion - $result | Should -Be $null - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "not installed" } + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Error checking if Chocolatey is installed:*" -and $Verbosity -eq "Error" + } -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 0 -Scope It + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It } } - - Context "When Chocolatey is installed and version is returned" { - It "Should return the trimmed version string" { + + Context "When Find-Chocolatey fails" { + It "Should return null and log error when Find-Chocolatey throws exception" { + # Arrange + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { throw "Find-Chocolatey error" } + Mock Write-StatusMessage { } + Mock Invoke-Command { } + Mock Test-Path { } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Error locating Chocolatey command:*" -and $Verbosity -eq "Error" + } -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Scope It + Assert-MockCalled Test-Path -Exactly 0 -Scope It + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + + It "Should return null and log warning when Find-Chocolatey returns null" { + # Arrange + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $null } + Mock Write-StatusMessage { } + Mock Test-Path { } + Mock Invoke-Command { } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Could not find Chocolatey command. Cannot retrieve version." -and $Verbosity -eq "Warning" + } + Assert-MockCalled Test-Path -Exactly 0 -Scope It + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + + It "Should return null and log warning when Find-Chocolatey returns empty string" { + # Arrange + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "" } + Mock Write-StatusMessage { } + Mock Test-Path { } + Mock Invoke-Command { } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Could not find Chocolatey command. Cannot retrieve version." -and $Verbosity -eq "Warning" + } + Assert-MockCalled Test-Path -Exactly 0 -Scope It + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + + It "Should return null and log warning when Find-Chocolatey returns whitespace" { + # Arrange Mock Test-ChocolateyInstalled { return $true } - Mock Invoke-Expression { " 1.4.0 " } + Mock Find-Chocolatey { return " " } + Mock Write-StatusMessage { } + Mock Test-Path { } + Mock Invoke-Command { } + + # Act $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Could not find Chocolatey command. Cannot retrieve version." -and $Verbosity -eq "Warning" + } + Assert-MockCalled Test-Path -Exactly 0 -Scope It + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + + It "Should return null and log warning when choco command path does not exist" { + # Arrange + $testChocoPath = Join-Path $TestDrive "nonexistent\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Write-StatusMessage { } + Mock Test-Path { return $false } + Mock Invoke-Command { } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $testChocoPath + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Chocolatey command path '$testChocoPath' does not exist. Cannot retrieve version." -and $Verbosity -eq "Warning" + } + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + + It "Should return null and log error when Test-Path throws exception" { + # Arrange + $testChocoPath = Join-Path $TestDrive "problematic\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Write-StatusMessage { } + Mock Test-Path { throw "Test-Path access denied" } + Mock Invoke-Command { } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $testChocoPath + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Error verifying Chocolatey command path:*" -and $Verbosity -eq "Error" + } -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Scope It + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + } + } + + Context "When version retrieval succeeds" { + It "Should return version string when Invoke-Command succeeds with version output and exit code 0" { + # Arrange + $testChocoPath = Join-Path $TestDrive "choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "1.4.0" + } + + # Act + $result = Get-ChocolateyVersion + + # Assert $result | Should -Be "1.4.0" + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $testChocoPath + } + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It -ParameterFilter { + $ScriptBlock.ToString() -match "--version" + } + Assert-MockCalled Write-StatusMessage -Exactly 0 -Scope It + } + + It "Should return version string with whitespace when output has whitespace and exit code 0" { + # Arrange + $testChocoPath = Join-Path $TestDrive "chocolatey\bin\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @(" 1.4.0`r`n ") # Return as array with complex whitespace + } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -Be " 1.4.0`r`n " + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 0 -Scope It + } + + It "Should handle different version formats correctly when exit code 0" { + # Arrange + $testChocoPath = Join-Path $TestDrive "custom\path\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "2.1.0-beta1" + } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -Be "2.1.0-beta1" + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 0 -Scope It + } + + It "Should return version string with complex whitespace as-is" { + # Arrange + $testChocoPath = Join-Path $TestDrive "program files\chocolatey\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + # Create a string with multiple types of whitespace + $whiteSpaceString = "`t`r`n 1.5.0 `r`n`t" + return $whiteSpaceString + } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -Be "`t`r`n 1.5.0 `r`n`t" + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 0 -Scope It } } - - Context "When Chocolatey is installed but version is not returned" { - It "Should return null and write a warning" { + + Context "When version retrieval fails" { + It "Should return null and log warning when Invoke-Command returns empty output" { + # Arrange + $testChocoPath = Join-Path $TestDrive "empty\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to retrieve Chocolatey version." -and $Verbosity -eq "Warning" + } + } + + It "Should return null and log warning when Invoke-Command returns empty string" { + # Arrange + $testChocoPath = Join-Path $TestDrive "chocolatey\tools\choco" Mock Test-ChocolateyInstalled { return $true } - Mock Invoke-Expression { $null } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "" + } + + # Act $result = Get-ChocolateyVersion - $result | Should -Be $null - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to retrieve" } + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to retrieve Chocolatey version." -and $Verbosity -eq "Warning" + } + } + + It "Should return null and log warning when LASTEXITCODE is not 0" { + # Arrange + $testChocoPath = Join-Path $TestDrive "error\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 1 + return "Some error output" + } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to retrieve Chocolatey version." -and $Verbosity -eq "Warning" + } + } + + It "Should return null and log warning when LASTEXITCODE is not 0 and output is empty" { + # Arrange + $testChocoPath = Join-Path $TestDrive "failed\path\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 2 + return $null + } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to retrieve Chocolatey version." -and $Verbosity -eq "Warning" + } + } + + It "Should return null and log error when Invoke-Command throws exception" { + # Arrange + $testChocoPath = Join-Path $TestDrive "error\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { throw "Command execution failed" } + + # Act + $result = Get-ChocolateyVersion + + # Assert + $result | Should -BeNullOrEmpty + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "An error occurred while trying to get Chocolatey version:*" -and $Verbosity -eq "Error" + } -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Scope It } } + + Context "Integration scenarios" { + It "Should use the correct chocolatey path from Find-Chocolatey" { + # Arrange + $customChocoPath = Join-Path $TestDrive "Custom\Path\choco" + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $customChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "1.4.0" + } -Verifiable -ParameterFilter { + $ScriptBlock.ToString() -match "--version" -and $ScriptBlock.ToString() -match "\`$chocoCommand" + } + + # Act + $result = Get-ChocolateyVersion - Context "When an error occurs during version retrieval" { - It "Should return null and write a warning" { + # Assert + $result | Should -Be "1.4.0" + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $customChocoPath + } + Assert-VerifiableMock + } + + It "Should suppress stderr output from chocolatey command" { + # Arrange + $testChocoPath = Join-Path $TestDrive "bin\choco" Mock Test-ChocolateyInstalled { return $true } - Mock Invoke-Expression { throw "choco error" } + Mock Find-Chocolatey { return $testChocoPath } + Mock Test-Path { return $true } + Mock Write-StatusMessage { } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "1.4.0" + } + + # Act $result = Get-ChocolateyVersion - $result | Should -Be $null - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "An error occurred" } + + # Assert + $result | Should -Be "1.4.0" + Assert-MockCalled Test-ChocolateyInstalled -Exactly 1 -Scope It + Assert-MockCalled Find-Chocolatey -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.ps1 b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.ps1 index 0f14d5d..358b714 100644 --- a/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Get-ChocolateyVersion.ps1 @@ -59,21 +59,53 @@ Function Get-ChocolateyVersion { Param( ) - if (-not (Test-ChocolateyInstalled)) { - Write-Warning "Chocolatey is not installed. Cannot retrieve version." + try { + if (-not (Test-ChocolateyInstalled)) { + Write-StatusMessage "Chocolatey is not installed. Cannot retrieve version." -Verbosity Warning + return $null + } + } catch { + Write-StatusMessage "Error checking if Chocolatey is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $chocoCommand = Find-Chocolatey + } catch { + Write-StatusMessage "Error locating Chocolatey command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + if(-not $chocoCommand -or [string]::IsNullOrWhiteSpace($chocoCommand)) { + Write-StatusMessage "Could not find Chocolatey command. Cannot retrieve version." -Verbosity Warning return $null } try { - $version = Invoke-Expression "& choco --version" 2>$null - if ($version) { - return $version.Trim() - } else { - Write-Warning "Failed to retrieve Chocolatey version." + if( -not (Test-Path $chocoCommand)) { + Write-StatusMessage "Chocolatey command path '$chocoCommand' does not exist. Cannot retrieve version." -Verbosity Warning return $null } } catch { - Write-Warning "An error occurred while trying to get Chocolatey version: $_" + Write-StatusMessage "Error verifying Chocolatey command path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $version = Invoke-Command -ScriptBlock { & $chocoCommand --version } + } catch { + Write-StatusMessage "An error occurred while trying to get Chocolatey version: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + if ($LASTEXITCODE -eq 0 -and $version) { + return $version + } else { + Write-StatusMessage "Failed to retrieve Chocolatey version." -Verbosity Warning return $null } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.Tests.ps1 index e213399..1687d18 100644 --- a/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.Tests.ps1 @@ -3,94 +3,194 @@ BeforeAll { . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\Test-ChocolateyInstalled.ps1 + Mock Write-StatusMessage { } Mock Write-Host { } Mock Write-Error { } Mock Test-RunningAsAdmin { return $true } - Mock Get-Command { $null } - Mock Invoke-Expression { } + Mock Test-OperatingSystem { param($Windows) $true } + Mock Test-ChocolateyInstalled { return $false } Mock Set-ExecutionPolicy { } + Mock New-Object -MockWith { + $mockWebClient = New-Object PSObject + Add-Member -InputObject $mockWebClient -MemberType ScriptMethod -Name DownloadString -Value { param($url) return "# Chocolatey install script content" } + Add-Member -InputObject $mockWebClient -MemberType ScriptMethod -Name Dispose -Value { } + return $mockWebClient + } -ParameterFilter { $TypeName -eq "System.Net.WebClient" } + Mock Invoke-Expression { } } Describe "Install-Chocolatey" { Context "When not running on Windows" { - It "Should skip installation and return true" { - Mock Test-OperatingSystem { param($Windows) $false } + It "Should skip installation and write status message" { + Mock Test-OperatingSystem { param($Windows) return $false } + $result = Install-Chocolatey + $result | Should -Be $true - Assert-MockCalled Write-Host -Exactly 1 -Scope It -ParameterFilter { $Object -match "not available on this platform" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey is not available on this platform" -and $Verbosity -eq "Error" + } + } + } + + Context "When Test-OperatingSystem throws an exception" { + It "Should handle operating system check exception and return false" { + Mock Test-OperatingSystem { throw "Operating system check failed" } + + $result = Install-Chocolatey + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking operating system" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } } } Context "When not running as administrator" { - It "Should throw and return false" { - Mock Test-OperatingSystem { param($Windows) $true } + It "Should write error message and return false" { + Mock Test-OperatingSystem { param($Windows) return $true } Mock Test-RunningAsAdmin { return $false } + $result = Install-Chocolatey + $result | Should -Be $false - Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -match "administrator privileges" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey installation requires administrator privileges" -and $Verbosity -eq "Error" + } } } Context "When Chocolatey is already installed" { - It "Should return true and show version" { - Mock Test-OperatingSystem { param($Windows) $true } + It "Should return true and show already installed message" { + Mock Test-OperatingSystem { param($Windows) return $true } Mock Test-RunningAsAdmin { return $true } - Mock Get-Command { [PSCustomObject]@{ Name = "choco" } } - Mock Invoke-Expression { "1.4.0" } + Mock Test-ChocolateyInstalled { return $true } + $result = Install-Chocolatey + $result | Should -Be $true - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "[OK]" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey is already installed" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } } } - Context "When Chocolatey is not installed and installation succeeds" { - It "Should install and return true" { - Mock Test-OperatingSystem { param($Windows) $true } - $script:installCalled = $false - $script:commandCallCount = 0 + Context "When Test-ChocolateyInstalled throws an exception" { + It "Should handle exception and return false" { + Mock Test-OperatingSystem { param($Windows) return $true } Mock Test-RunningAsAdmin { return $true } - Mock Get-Command -MockWith { - $script:commandCallCount++ - if ($script:commandCallCount -eq 1) { return $null } - else { return [PSCustomObject]@{ Name = "choco" } } + Mock Test-ChocolateyInstalled { throw "Test-ChocolateyInstalled failed" } + + $result = Install-Chocolatey + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error checking Chocolatey installation" -and $Verbosity -eq "Error" } - Mock Invoke-Expression -MockWith { - param($expr) - if ($expr -like "*--version*") { return "1.4.0" } - $script:installCalled = $true + } + } + + Context "When Chocolatey is not installed and installation succeeds" { + It "Should install successfully and verify with Test-ChocolateyInstalled" { + Mock Test-OperatingSystem { param($Windows) return $true } + Mock Test-RunningAsAdmin { return $true } + $script:installCheckCount = 0 + Mock Test-ChocolateyInstalled -MockWith { + $script:installCheckCount++ + if ($script:installCheckCount -eq 1) { return $false } # Initial check + else { return $true } # Post-install verification } + $result = Install-Chocolatey + $result | Should -Be $true - $script:installCalled | Should -Be $true - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "[OK]" } + Assert-MockCalled Set-ExecutionPolicy -Exactly 1 -Scope It -ParameterFilter { + $ExecutionPolicy -eq "Bypass" -and $Scope -eq "Process" -and $Force -eq $true + } + Assert-MockCalled New-Object -Exactly 1 -Scope It -ParameterFilter { + $TypeName -eq "System.Net.WebClient" + } + Assert-MockCalled Invoke-Expression -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When Chocolatey installation fails verification" { + It "Should return false and write FAILED when Test-ChocolateyInstalled still returns false" { + Mock Test-OperatingSystem { param($Windows) return $true } + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $false } # Always returns false + + $result = Install-Chocolatey + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "[FAILD]" -and $ForegroundColor -eq "Red" + } } } - Context "When Chocolatey is not installed and installation fails" { - It "Should return false and write error" { - Mock Test-OperatingSystem { param($Windows) $true } - $script:commandCallCount = 0 + Context "When installation process fails" { + It "Should handle installation exception and return false" { + Mock Test-OperatingSystem { param($Windows) return $true } Mock Test-RunningAsAdmin { return $true } - Mock Get-Command -MockWith { - $script:commandCallCount++ - return $null + Mock Test-ChocolateyInstalled { return $false } + Mock Invoke-Expression { throw "Network connection failed" } + + $result = Install-Chocolatey + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error during Chocolatey installation" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" + } + } + } + + Context "When verification fails with exception" { + It "Should handle verification exception and return false" { + Mock Test-OperatingSystem { param($Windows) return $true } + Mock Test-RunningAsAdmin { return $true } + $script:installCheckCount = 0 + Mock Test-ChocolateyInstalled -MockWith { + $script:installCheckCount++ + if ($script:installCheckCount -eq 1) { return $false } # Initial check + else { throw "Verification failed" } # Post-install verification throws } - Mock Invoke-Expression { } + $result = Install-Chocolatey + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Failed to install" } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Error verifying Chocolatey installation" -and $Verbosity -eq "Error" + } } } Context "When an unexpected error occurs" { - It "Should return false and write error" { - Mock Test-OperatingSystem { param($Windows) $true } - Mock Test-RunningAsAdmin { throw "Unexpected error" } + It "Should return false and write comprehensive error message" { + Mock Test-OperatingSystem { param($Windows) return $true } + Mock Test-RunningAsAdmin { throw "Unexpected system error" } + $result = Install-Chocolatey + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Error checking/installing Chocolatey" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking administrator privileges" -and $Verbosity -eq "Error" + } } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.ps1 b/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.ps1 index 30d386f..533446d 100644 --- a/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Install-Chocolatey.ps1 @@ -67,47 +67,69 @@ Function Install-Chocolatey { try { # Check if we're on Windows - Chocolatey is Windows-only if (-not (Test-OperatingSystem -Windows)) { - Write-Host "Chocolatey is not available on this platform. Skipping installation." -ForegroundColor Yellow + Write-StatusMessage "Chocolatey is not available on this platform. Skipping installation." -Verbosity Error return $true } + } catch { + Write-StatusMessage "Error checking operating system: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + try { # Check if running as administrator if (-not (Test-RunningAsAdmin)) { - throw "Chocolatey installation requires administrator privileges. Please run as administrator." + Write-StatusMessage "Chocolatey installation requires administrator privileges. Please run as administrator." -Verbosity Error + return $false } - - Write-StatusMessage "- Installing Chocolatey package manager" -ForegroundColor Gray -Indent 2 -Width 77 -NoNewline - # Check if chocolatey is installed by testing the command - $chocoInstalled = Get-Command choco -ErrorAction SilentlyContinue - + } catch { + Write-StatusMessage "Error checking administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "- Installing Chocolatey package manager" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + try { + # Check if chocolatey is already installed + if (Test-ChocolateyInstalled) { + Write-StatusMessage "Chocolatey is already installed. Skipping installation." -Verbosity Debug + Write-StatusMessage "[OK]" -ForegroundColor Green + return $true + } + } catch { + Write-StatusMessage "Error checking Chocolatey installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + # Set security protocols and execution policy + Set-ExecutionPolicy Bypass -Scope Process -Force | Out-Null + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + + # Download and install Chocolatey + (Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) *> $null) *> $null + } catch { + Write-StatusMessage "Error during Chocolatey installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + Write-StatusMessage "[FAILED]" -ForegroundColor Red + return $false + } + + # Verify installation + try { + $chocoInstalled = Test-ChocolateyInstalled if ($chocoInstalled) { - Invoke-Expression "& choco --version" *>$null - #Write-Host "Chocolatey is already installed (version: $chocoVersion)" -ForegroundColor Green + #Write-Host "Chocolatey successfully installed (version: $chocoVersion)!" -ForegroundColor Green Write-StatusMessage "[OK]" -ForegroundColor Green + return $true } else { - #Write-Host "Chocolatey not found. Installing Chocolatey..." -ForegroundColor Cyan - - # Set security protocols and execution policy - Set-ExecutionPolicy Bypass -Scope Process -Force | Out-Null - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - - # Download and install Chocolatey - (Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) *> $null) *> $null - - # Verify installation - $chocoInstalled = Get-Command choco -ErrorAction SilentlyContinue - if ($chocoInstalled) { - Invoke-Expression "& choco --version" *>$null - #Write-Host "Chocolatey successfully installed (version: $chocoVersion)!" -ForegroundColor Green - Write-StatusMessage "[OK]" -ForegroundColor Green - } else { - throw "Failed to install Chocolatey" - } + Write-StatusMessage "[FAILD]" -ForegroundColor Red + return $false } - return $true - } - catch { - Write-Error "Error checking/installing Chocolatey: $_" + } catch { + Write-StatusMessage "Error verifying Chocolatey installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false - } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.Tests.ps1 index 19c32ae..7f66fdf 100644 --- a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.Tests.ps1 @@ -1,105 +1,560 @@ BeforeAll { . (Join-Path $PSScriptRoot "Install-ChocolateyPackage.ps1") + . (Join-Path $PSScriptRoot "Test-ChocolateyInstalled.ps1") . (Join-Path $PSScriptRoot "Test-ChocolateyPackageInstalled.ps1") . (Join-Path $PSScriptRoot "Uninstall-ChocolateyPackage.ps1") + . (Join-Path $PSScriptRoot "Find-Chocolatey.ps1") . (Join-Path $PSScriptRoot "Write-ChocolateyCache.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1") - Mock Test-RunningAsAdmin { $true } - Mock Test-ChocolateyPackageInstalled { } - Mock Uninstall-ChocolateyPackage { $true } - Mock Get-Command { "choco" } - Mock Invoke-Command { } - Mock Write-ChocolateyCache { $true } - Mock Write-Debug { } - Mock Write-Warning { } - Mock Write-Error { } + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Test-RunningAsAdmin.ps1") + . (Join-Path $PSScriptRoot "..\..\Enums\InstalledState.ps1") + + Mock Write-StatusMessage { } } Describe "Install-ChocolateyPackage" { Context "When not running as administrator" { - It "Should throw and return false" { - Mock Test-RunningAsAdmin { $false } - $result = Install-ChocolateyPackage -PackageName "azshell" + It "Should return false and write error message" { + Mock Test-RunningAsAdmin { return $false } + + $result = Install-ChocolateyPackage -PackageName "git" + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "administrator privileges" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "requires administrator privileges" -and $Verbosity -eq "Error" + } + } + } + + Context "When Test-RunningAsAdmin throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking administrator privileges" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Chocolatey is not installed" { + It "Should return false and write warning message" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $false } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey is not installed. Cannot install package git." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Test-ChocolateyInstalled throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { throw "Installation check failed" } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking if Chocolatey is installed" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Test-ChocolateyPackageInstalled throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { throw "Package check failed" } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking if package git is installed" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When package already meets requirements" { + It "Should return true immediately when package passes all checks" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::Pass + return $result + } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $true + Assert-MockCalled Test-ChocolateyPackageInstalled -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "git" -and -not $PSBoundParameters.ContainsKey('Version') + } + } + + It "Should return true immediately when package with version passes all checks" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::Pass + return $result + } + + $result = Install-ChocolateyPackage -PackageName "nodejs" -Version "20.10.0" + + $result | Should -Be $true + Assert-MockCalled Test-ChocolateyPackageInstalled -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "nodejs" -and $Version -eq "20.10.0" + } + } + } + + Context "When package needs reinstallation due to version conflict" { + It "Should uninstall existing package before reinstalling" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::Installed + return $result + } + Mock Uninstall-ChocolateyPackage { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + } + Mock Write-ChocolateyCache { return $true } + + $result = Install-ChocolateyPackage -PackageName "nodejs" -Version "18.17.0" + + $result | Should -Be $true + Assert-MockCalled Uninstall-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "nodejs" + } + Assert-MockCalled Invoke-Command -Times 1 -Scope It + } + + It "Should handle uninstall failure and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::Installed + return $result + } + Mock Uninstall-ChocolateyPackage { throw "Uninstall failed" } + + $result = Install-ChocolateyPackage -PackageName "nodejs" -Version "18.17.0" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error uninstalling existing package nodejs" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { throw "Cannot locate chocolatey" } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error locating Chocolatey command" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey returns null or invalid path" { + It "Should return false when Find-Chocolatey returns null" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return $null } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot install package git." -and $Verbosity -eq "Warning" + } + } + + It "Should return false when Find-Chocolatey returns empty string" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "" } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot install package git." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Test-Path throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { throw "Path check failed" } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error verifying Chocolatey command path" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } } } - Context "When package is already installed and version matches" { - It "Should return true immediately" { + Context "When Chocolatey command path does not exist" { + It "Should return false when path does not exist" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } Mock Test-ChocolateyPackageInstalled { - return ([InstalledState]::Pass) + $result = [InstalledState]::NotInstalled + return $result } - $result = Install-ChocolateyPackage -PackageName "azshell" + Mock Find-Chocolatey { return "C:\invalid\path\choco.exe" } + Mock Test-Path { return $false } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey command path 'C:\\invalid\\path\\choco.exe' does not exist. Cannot install package git." -and $Verbosity -eq "Warning" + } + } + } + + Context "When installing package without version" { + It "Should install package with default parameters and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 0 + } + Mock Write-ChocolateyCache { return $true } + + $result = Install-ChocolateyPackage -PackageName "git" + $result | Should -Be $true + Assert-MockCalled Invoke-Command -Times 1 -Scope It + Assert-MockCalled Write-ChocolateyCache -Times 1 -Scope It } } - Context "When package is installed but version does not match" { - It "Should uninstall and reinstall the package" { + Context "When installing package with version" { + It "Should install package with version parameter and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } Mock Test-ChocolateyPackageInstalled { - return ([InstalledState]::Installed + [InstalledState]::MinimumVersionMet + [InstalledState]::GlobalVersionMet) + $result = [InstalledState]::NotInstalled + return $result } - $script:uninstallCalled = $false - Mock Uninstall-ChocolateyPackage -MockWith { - param($PackageName) - $script:uninstallCalled = $true - $true + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 0 } - $script:LASTEXITCODE = 0 - Mock Invoke-Command { - $script:LASTEXITCODE = 0 - } - $result = Install-ChocolateyPackage -PackageName "azshell" + Mock Write-ChocolateyCache { return $true } + + $result = Install-ChocolateyPackage -PackageName "nodejs" -Version "20.10.0" + $result | Should -Be $true - $script:uninstallCalled | Should -Be $true + Assert-MockCalled Invoke-Command -Times 1 -Scope It + Assert-MockCalled Write-ChocolateyCache -Times 1 -Scope It } } - Context "When installing with version and params" { - It "Should build the correct choco command" { - $script:LASTEXITCODE = 0 - $script:paramsPassed = $null - Mock Test-ChocolateyPackageInstalled { return ([InstalledState]::NotInstalled) } - Mock Invoke-Command -MockWith { + Context "When installing package with custom parameters" { + It "Should install package with params parameter and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { param($ScriptBlock) - $script:paramsPassed = $ScriptBlock.ToString() + $global:LASTEXITCODE = 0 } - $result = Install-ChocolateyPackage -PackageName "azshell" -Version "0.2.2" -Param "/silent" + Mock Write-ChocolateyCache { return $true } + + $result = Install-ChocolateyPackage -PackageName "googlechrome" -Param "/nogoogle" + $result | Should -Be $true - # You can add more checks for $paramsPassed if needed + Assert-MockCalled Invoke-Command -Times 1 -Scope It + Assert-MockCalled Write-ChocolateyCache -Times 1 -Scope It + } + + It "Should install package with version and params parameters" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 0 + } + Mock Write-ChocolateyCache { return $true } + + $result = Install-ChocolateyPackage -PackageName "vscode" -Version "1.84.2" -Param "/silent" + + $result | Should -Be $true + Assert-MockCalled Invoke-Command -Times 1 -Scope It + Assert-MockCalled Write-ChocolateyCache -Times 1 -Scope It } } - Context "When installation fails (non-zero exit code)" { - It "Should write error and return false" { - $script:LASTEXITCODE = 1 - Mock Test-ChocolateyPackageInstalled { return ([InstalledState]::NotInstalled) } - $result = Install-ChocolateyPackage -PackageName "azshell" + Context "When Invoke-Command throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { throw "Command execution failed" } + + $result = Install-ChocolateyPackage -PackageName "git" + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Failed to install" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error installing package git" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } } } - Context "When Write-ChocolateyCache fails after install" { - It "Should write warning and return false" { - $script:LASTEXITCODE = 0 - Mock Test-ChocolateyPackageInstalled { return ([InstalledState]::NotInstalled) } - Mock Write-ChocolateyCache { $false } - $result = Install-ChocolateyPackage -PackageName "azshell" + Context "When installation command fails with non-zero exit code" { + It "Should return false when LASTEXITCODE is non-zero" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 1 + } + + $result = Install-ChocolateyPackage -PackageName "git" + $result | Should -Be $false - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "Failed to write Chocolatey cache" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to install: git" -and $Verbosity -eq "Error" + } } } - Context "When an exception occurs during install" { - It "Should write error and return false" { - Mock Test-ChocolateyPackageInstalled { throw "Unexpected error" } - $result = Install-ChocolateyPackage -PackageName "azshell" + Context "When Write-ChocolateyCache fails after successful installation" { + It "Should return false when Write-ChocolateyCache returns false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 0 + } + Mock Write-ChocolateyCache { return $false } + + $result = Install-ChocolateyPackage -PackageName "git" + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Error checking/installing package" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to write Chocolatey cache." -and $Verbosity -eq "Error" + } + } + + It "Should handle Write-ChocolateyCache exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 0 + } + Mock Write-ChocolateyCache { throw "Cache write failed" } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error writing Chocolatey cache" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When using ShouldProcess with WhatIf" { + It "Should skip installation and return true when WhatIf is specified" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { } + + $result = Install-ChocolateyPackage -PackageName "git" -WhatIf + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Skipping installation of Chocolatey package 'git'." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Invoke-Command -Times 0 -Scope It + } + } + + Context "When validating parameter validation" { + It "Should throw when PackageName is null" { + { Install-ChocolateyPackage -PackageName $null } | Should -Throw + } + + It "Should throw when PackageName is empty string" { + { Install-ChocolateyPackage -PackageName "" } | Should -Throw + } + + It "Should throw when Version is empty string" { + { Install-ChocolateyPackage -PackageName "git" -Version "" } | Should -Throw + } + + It "Should throw when Param is empty string" { + { Install-ChocolateyPackage -PackageName "git" -Param "" } | Should -Throw + } + } + + Context "When processing successful installation scenarios" { + It "Should complete full installation flow successfully" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 0 + } + Mock Write-ChocolateyCache { return $true } + + $result = Install-ChocolateyPackage -PackageName "git" -Version "2.42.0" -Param "/VERYSILENT" + + $result | Should -Be $true + Assert-MockCalled Test-RunningAsAdmin -Times 1 -Scope It + Assert-MockCalled Test-ChocolateyInstalled -Times 1 -Scope It + Assert-MockCalled Test-ChocolateyPackageInstalled -Times 1 -Scope It + Assert-MockCalled Find-Chocolatey -Times 1 -Scope It + Assert-MockCalled Test-Path -Times 1 -Scope It + Assert-MockCalled Invoke-Command -Times 1 -Scope It + Assert-MockCalled Write-ChocolateyCache -Times 1 -Scope It + } + + It "Should handle minimal parameters correctly" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Test-ChocolateyPackageInstalled { + $result = [InstalledState]::NotInstalled + return $result + } + Mock Find-Chocolatey { return "choco.exe" } + Mock Test-Path { return $true } + Mock Invoke-Command { + param($ScriptBlock) + $global:LASTEXITCODE = 0 + } + Mock Write-ChocolateyCache { return $true } + + $result = Install-ChocolateyPackage -PackageName "git" + + $result | Should -Be $true + Assert-MockCalled Test-ChocolateyPackageInstalled -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "git" -and -not $PSBoundParameters.ContainsKey('Version') + } } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.ps1 b/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.ps1 index 6342b5f..fe6d60c 100644 --- a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackage.ps1 @@ -74,7 +74,7 @@ #> Function Install-ChocolateyPackage { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] @@ -92,63 +92,120 @@ Function Install-ChocolateyPackage { try { # Check if running as administrator if (-not (Test-RunningAsAdmin)) { - throw "Chocolatey package installation requires administrator privileges. Please run as administrator." - } - - $testParams = @{ - PackageName = $PackageName + Write-StatusMessage "Chocolatey package installation requires administrator privileges. Please run as administrator." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Error checking administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - if($PSBoundParameters.ContainsKey('Version')) { - $testParams.Version = $Version + try { + if (-not (Test-ChocolateyInstalled)) { + Write-StatusMessage "Chocolatey is not installed. Cannot install package $PackageName." -Verbosity Warning + return $false } + } catch { + Write-StatusMessage "Error checking if Chocolatey is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + $testParams = @{ + PackageName = $PackageName + } + + if($PSBoundParameters.ContainsKey('Version')) { + $testParams.Version = $Version + } + + try { $testResult = Test-ChocolateyPackageInstalled @testParams + } catch { + Write-StatusMessage "Error checking if package $PackageName is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - if($testResult.HasFlag([InstalledState]::Pass)) { - return $true - } + if($testResult.HasFlag([InstalledState]::Pass)) { + return $true + } - if($testResult.HasFlag([InstalledState]::Installed)) { + if($testResult.HasFlag([InstalledState]::Installed)) { + try { Uninstall-ChocolateyPackage -PackageName $PackageName | Out-Null + } catch { + Write-StatusMessage "Error uninstalling existing package $($PackageName): $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false } + } - $installParams = @( - 'install', - '-y', - $PackageName - ) - - if($PSBoundParameters.ContainsKey('Version')) { - $installParams = $installParams + @('--version', $Version) - } + $installParams = @( + 'install', + '-y', + $PackageName + ) + + if($PSBoundParameters.ContainsKey('Version')) { + $installParams = $installParams + @('--version', $Version) + } - if($PSBoundParameters.ContainsKey('Param')) { - $installParams = $installParams + @('--params', $Param) - } + if($PSBoundParameters.ContainsKey('Param')) { + $installParams = $installParams + @('--params', $Param) + } - $chocoCommand = Get-Command choco -ErrorAction SilentlyContinue + try { + $chocoCommand = Find-Chocolatey + if (-not $chocoCommand) { + Write-StatusMessage "Could not find Chocolatey command. Cannot install package $PackageName." -Verbosity Warning + return $false + } + } catch { + Write-StatusMessage "Error locating Chocolatey command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - $command = { - & $chocoCommand @installParams + try { + if( -not (Test-Path $chocoCommand)) { + Write-StatusMessage "Chocolatey command path '$chocoCommand' does not exist. Cannot install package $PackageName." -Verbosity Warning + return $false } + } catch { + Write-StatusMessage "Error verifying Chocolatey command path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - Invoke-Command -ScriptBlock $command | Out-Null - - if ($LASTEXITCODE -eq 0) { - Write-Debug "INSTALL:Successfully installed: $PackageName" + if ($PSCmdlet.ShouldProcess($PackageName, "Install Chocolatey package")) { + try { + Invoke-Command -ScriptBlock { & $chocoCommand @installParams | Out-Null } + } catch { + Write-StatusMessage "Error installing package $($PackageName): $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + } else { + Write-StatusMessage "Skipping installation of Chocolatey package '$PackageName'." -Verbosity Debug + return $true + } + + if ($LASTEXITCODE -eq 0) { + try { if (-not (Write-ChocolateyCache)) { - Write-Warning "Failed to write Chocolatey cache." + Write-StatusMessage "Failed to write Chocolatey cache." -Verbosity Error return $false } - return $true - } else { - Write-Error "Failed to install: $PackageName" + } catch { + Write-StatusMessage "Error writing Chocolatey cache: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false - } - } - catch { - Write-Error "Error checking/installing package $PackageName`: $_" + } + return $true + } else { + Write-StatusMessage "Failed to install: $PackageName" -Verbosity Error return $false - } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackages.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackages.Tests.ps1 deleted file mode 100644 index 75d45a7..0000000 --- a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackages.Tests.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -BeforeAll { - . $PSScriptRoot\Install-ChocolateyPackages.ps1 - . $PSScriptRoot\Install-ChocolateyPackage.ps1 - . $PSScriptRoot\Write-ChocolateyCache.ps1 - . $PSScriptRoot\Read-ChocolateyCache.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 - Mock Test-RunningAsAdmin { $true } - Mock Write-ChocolateyCache { $true } - Mock Write-Warning { } - Mock Write-StatusMessage { } -Verifiable - Mock Install-ChocolateyPackage { $true } - Mock Write-Error {} - Mock Write-Host {} -} - -Describe "Install-ChocolateyPackages" { - - Context "When not running as administrator" { - It "Should throw and return false" { - Mock Test-RunningAsAdmin { $false } - $result = Install-ChocolateyPackages -YamlData @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @("git") } } } } - $result | Should -Be $false - } - } - - Context "When Chocolatey packages config is missing" { - It "Should write warning and return" { - $yamlData = @{ devsetup = @{ dependencies = @{ } } } - $result = Install-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $null - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "not found" } - } - } - - Context "When Write-ChocolateyCache fails" { - It "Should write warning and return false" { - Mock Write-ChocolateyCache { $false } - $yamlData = @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @("git") } } } } - $result = Install-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $false - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to write Chocolatey cache" } - } - } - - Context "When all packages install successfully (string format)" { - It "Should process all packages and return true" { - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @("git", "nodejs") - } - } - } - } - $result = Install-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Install-ChocolateyPackage -Exactly 2 -Scope It - #Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Object -match "installation completed" } - } - } - - Context "When all packages install successfully (object format)" { - It "Should process all packages and return true" { - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @( - @{ name = "git"; version = "2.42.0" }, - @{ name = "nodejs"; params = "/silent" } - ) - } - } - } - } - $result = Install-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Install-ChocolateyPackage -Exactly 2 -Scope It - } - } - - Context "When some packages fail to install" { - It "Should continue processing and return true" { - $callCount = 0 - Mock Install-ChocolateyPackage -MockWith { - param($PackageName, $Version, $Param) - $callCount++ - if ($callCount -eq 1) { $true } else { $false } - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @("git", "nodejs") - } - } - } - } - $result = Install-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Install-ChocolateyPackage -Exactly 2 -Scope It - #Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Object -eq "[FAILED]" } - } - } - - Context "When package entry is empty or missing name" { - It "Should skip invalid entries and continue" { - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @( - $null, - @{ version = "1.0.0" }, - "git" - ) - } - } - } - } - $result = Install-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Install-ChocolateyPackage -Exactly 1 -Scope It - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "no name specified" } - } - } - - Context "When an exception occurs during installation" { - It "Should write error and return false" { - Mock Install-ChocolateyPackage { throw "Unexpected error" } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @("git") - } - } - } - } - $result = Install-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Error installing Chocolatey packages" } - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageExport.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageExport.Tests.ps1 new file mode 100644 index 0000000..c35e179 --- /dev/null +++ b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageExport.Tests.ps1 @@ -0,0 +1,621 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-ChocolateyPackageExport.ps1") + . (Join-Path $PSScriptRoot "Test-ChocolateyInstalled.ps1") + . (Join-Path $PSScriptRoot "Find-Chocolatey.ps1") + . (Join-Path $PSScriptRoot "Get-ChocolateyPackageDependencyMap.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Test-RunningAsAdmin.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Update-DevSetupEnvFile.ps1") + + Mock Write-StatusMessage { } +} + +Describe "Invoke-ChocolateyPackageExport" { + + Context "When not running as administrator" { + It "Should return false and write error message" { + Mock Test-RunningAsAdmin { return $false } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "requires administrator privileges" -and $Verbosity -eq "Error" + } + } + } + + Context "When Test-RunningAsAdmin throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking administrator privileges" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Chocolatey is not installed" { + It "Should return false and write warning message" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $false } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey is not installed. Cannot export packages." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Test-ChocolateyInstalled throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { throw "Installation check failed" } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking if Chocolatey is installed" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { throw "Cannot locate chocolatey" } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error locating Chocolatey command" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey returns null or empty" { + It "Should return false when Find-Chocolatey returns null" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $null } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot export packages." -and $Verbosity -eq "Warning" + } + } + + It "Should return false when Find-Chocolatey returns empty string" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "" } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot export packages." -and $Verbosity -eq "Warning" + } + } + + It "Should return false when Find-Chocolatey returns whitespace" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return " " } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot export packages." -and $Verbosity -eq "Warning" + } + } + } + + Context "When chocolatey command execution fails" { + It "Should handle Invoke-Command exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { throw "Command execution failed" } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to retrieve Chocolatey package list" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + + It "Should handle non-zero exit code and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 1 + return "error output" + } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to retrieve Chocolatey package list" -and $Verbosity -eq "Error" + } + } + } + + Context "When chocolatey command returns no packages" { + It "Should return true and write warning when command returns null" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "No Chocolatey packages found or Chocolatey is not installed." -and $Verbosity -eq "Warning" + } + } + + It "Should return true and write warning when command returns empty string" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "" + } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "No Chocolatey packages found or Chocolatey is not installed." -and $Verbosity -eq "Warning" + } + } + + It "Should return true and write warning when command returns whitespace" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return " " + } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "No Chocolatey packages found or Chocolatey is not installed." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Get-ChocolateyPackageDependencyMap throws an exception" { + It "Should handle exception and continue with empty ignore list" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "git|2.42.0" + } + Mock Get-ChocolateyPackageDependencyMap { throw "Dependency map failed" } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to retrieve Chocolatey package dependency map" -and $Verbosity -eq "Warning" + } + } + } + + Context "When processing packages with filtering" { + It "Should skip packages starting with chocolatey" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("chocolatey|0.12.1", "chocolatey-core|0.12.1", "git|2.42.0") + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Message -match "Skipping chocolatey package:" -and $Verbosity -eq "Verbose" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Found 1 Chocolatey packages" -and $Verbosity -eq "Debug" + } + } + + It "Should skip packages in ignore list from dependency map" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0", "nodejs|20.10.0", "ignored-package|1.0.0") + } + Mock Get-ChocolateyPackageDependencyMap { return @("ignored-package") } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Skipping ignored package: ignored-package" -and $Verbosity -eq "Verbose" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Found 2 Chocolatey packages" -and $Verbosity -eq "Debug" + } + } + + It "Should process packages with proper name and version parsing" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0.20231018", "nodejs|20.10.0", "vscode|1.84.2") + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Found package: git \(version: 2\.42\.0\.20231018\)" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Found package: nodejs \(version: 20\.10\.0\)" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Found package: vscode \(version: 1\.84\.2\)" -and $Verbosity -eq "Debug" + } + } + + It "Should skip lines with invalid format" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("", "invalid-line", "git|2.42.0", "another-invalid", "nodejs|20.10.0") + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Found 2 Chocolatey packages" -and $Verbosity -eq "Debug" + } + } + } + + Context "When Read-DevSetupEnvFile fails" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "git|2.42.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { throw "Failed to read YAML" } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to read YAML configuration from test.yaml" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When processing packages against existing configuration" { + It "Should add new package not in existing configuration" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "git|2.42.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "nodejs"; version = "20.10.0" } + ) + } + } + } + } + } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Adding package: git \(2\.42\.0\)" -and $ForegroundColor -eq "Gray" -and $Indent -eq 2 -and $Width -eq 112 -and $NoNewline -eq $true + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + } + + It "Should update existing package when version changes" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "nodejs|20.11.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + $existingPackage = @{ name = "nodejs"; version = "20.10.0" } + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @($existingPackage) + } + } + } + } + } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Updating package: nodejs \(20\.10\.0 -> 20\.11\.0\)" -and $ForegroundColor -eq "Cyan" -and $Indent -eq 2 -and $Width -eq 112 -and $NoNewline -eq $true + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + } + + It "Should update existing package when no version exists" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "nodejs|20.10.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + $existingPackage = @{ name = "nodejs" } + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @($existingPackage) + } + } + } + } + } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Updating package: nodejs" -and $ForegroundColor -eq "Gray" -and $Indent -eq 2 -and $Width -eq 112 -and $NoNewline -eq $true + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + } + + It "Should skip existing package with same version" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "nodejs|20.10.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + $existingPackage = @{ name = "nodejs"; version = "20.10.0" } + Mock Read-DevSetupEnvFile { + return @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @($existingPackage) + } + } + } + } + } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Skipping package \(No Change\): nodejs \(20\.10\.0\)" -and $ForegroundColor -eq "Gray" -and $Indent -eq 2 -and $Width -eq 112 -and $NoNewline -eq $true + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Gray" + } + } + } + + Context "When Update-DevSetupEnvFile fails" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "git|2.42.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { throw "Failed to save YAML" } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to save configuration to test.yaml" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When using DryRun parameter" { + It "Should pass WhatIf to Update-DevSetupEnvFile" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "git|2.42.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" -DryRun + + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Times 1 -Scope It -ParameterFilter { + $WhatIf -eq $true + } + } + } + + Context "When validating parameter validation" { + It "Should throw when Config is null" { + { Invoke-ChocolateyPackageExport -Config $null } | Should -Throw + } + + It "Should throw when Config is empty string" { + { Invoke-ChocolateyPackageExport -Config "" } | Should -Throw + } + } + + Context "When processing successful export operation" { + It "Should complete export successfully with multiple packages" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0", "nodejs|20.10.0", "vscode|1.84.2") + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Getting list of installed Chocolatey packages..." -and $ForegroundColor -eq "Gray" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Found 3 Chocolatey packages" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 3 -Scope It -ParameterFilter { + $Message -match "Found package:" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 3 -Scope It -ParameterFilter { + $Message -match "Adding package:" -and $ForegroundColor -eq "Gray" + } + Assert-MockCalled Write-StatusMessage -Exactly 3 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Saving configuration to:" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Configuration saved successfully!" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Chocolatey packages conversion completed!" -and $ForegroundColor -eq "Green" + } + } + + It "Should write proper console messages in the correct sequence" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "git|2.42.0" + } + Mock Get-ChocolateyPackageDependencyMap { return @() } + Mock Read-DevSetupEnvFile { return @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @() } } } } } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ChocolateyPackageExport -Config "test.yaml" + + $result | Should -Be $true + # Expected messages: Getting list...(1) + Found 1 packages(1) + Found package(1) + Adding package(1) + [OK](1) + Saving(1) + saved(1) + completed(1) = 8 total + Assert-MockCalled Write-StatusMessage -Exactly 8 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageExport.ps1 b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageExport.ps1 new file mode 100644 index 0000000..b3a4892 --- /dev/null +++ b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageExport.ps1 @@ -0,0 +1,236 @@ +๏ปฟ<# +.SYNOPSIS + Exports installed Chocolatey packages to a YAML configuration file. + +.DESCRIPTION + This function scans the system for installed Chocolatey packages and exports them to a YAML + configuration file in DevSetup format. It uses 'choco list --local-only --limit-output' to retrieve + comprehensive package information including versions. The function intelligently filters out + system packages and can update existing configuration files by merging new packages with existing ones. + +.PARAMETER Config + The path to the YAML configuration file to read from and write to. + This parameter is mandatory and specifies both the input and output file unless OutFile is specified. + +.PARAMETER OutFile + The path to save the updated YAML configuration. + Optional parameter that allows saving to a different file than the input Config file. + +.PARAMETER DryRun + Switch parameter that prevents writing to files and displays the resulting configuration to the console. + Useful for previewing changes before committing them to a file. + +.OUTPUTS + [System.Boolean] + Returns $true if the export completes successfully or if no packages are found. + Returns $false if there are errors during the export process. + +.EXAMPLE + Invoke-ChocolateyPackageExport -Config "environment.yaml" + + Exports installed Chocolatey packages to the existing environment.yaml configuration file. + +.EXAMPLE + Invoke-ChocolateyPackageExport -Config "current.yaml" -OutFile "backup.yaml" + + Reads from current.yaml and saves the updated configuration with installed packages to backup.yaml. + +.EXAMPLE + Invoke-ChocolateyPackageExport -Config "dev-env.yaml" -DryRun + + Shows what the configuration would look like without actually saving to file. + +.NOTES + - Requires administrator privileges to access all installed packages + - Uses 'choco list --local-only --limit-output' for machine-readable package information + - Automatically filters out system packages: + * Packages ending with '.install' (installer packages) + * Packages starting with 'chocolatey' (Chocolatey system packages) + - Merges with existing YAML configuration, preserving other sections and structure + - Supports both simple string format and complex object format for packages + - Updates existing packages when versions have changed + - Converts string entries to hashtable format when version information is added + - Creates the devsetup.dependencies.chocolatey structure if it doesn't exist + - Provides detailed console output with color-coded status messages for operations + - Handles YAML conversion errors gracefully by falling back to JSON format + - Tracks package changes: new additions, version updates, and no-change skips + +.LINK + +.COMPONENT + DevSetup.Providers.Chocolatey + +.FUNCTIONALITY + Configuration Export, Package Discovery, YAML Generation +#> + +Function Invoke-ChocolateyPackageExport { + [CmdletBinding()] + [OutputType([bool])] + Param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Config, + [Parameter(Mandatory = $false)] + [switch]$DryRun + ) + + try { + # Check if running as administrator + if (-not (Test-RunningAsAdmin)) { + Write-StatusMessage "This operation requires administrator privileges. Please run as administrator." -Verbosity Error + return $false + } + } catch { + Write-StatusMessage "Error checking administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + if (-not (Test-ChocolateyInstalled)) { + Write-StatusMessage "Chocolatey is not installed. Cannot export packages." -Verbosity Warning + return $false + } + } catch { + Write-StatusMessage "Error checking if Chocolatey is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + $chocoCommand = Find-Chocolatey + } catch { + Write-StatusMessage "Error locating Chocolatey command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + if(-not $chocoCommand -or [string]::IsNullOrWhiteSpace($chocoCommand)) { + Write-StatusMessage "Could not find Chocolatey command. Cannot export packages." -Verbosity Warning + return $false + } + + # Get list of installed Chocolatey packages + Write-StatusMessage "- Getting list of installed Chocolatey packages..." -ForegroundColor Gray + try { + $chocoList = Invoke-Command -ScriptBlock { & $chocoCommand list --local-only --limit-output } + if($LASTEXITCODE -ne 0) { + throw "Chocolatey command failed with exit code $LASTEXITCODE" + } + } catch { + Write-StatusMessage "Failed to retrieve Chocolatey package list: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + if (-not $chocoList -or [string]::IsNullOrWhiteSpace($chocoList)) { + Write-StatusMessage "No Chocolatey packages found or Chocolatey is not installed." -Verbosity Warning + return $true + } + + $chocolateyPackages = @() + + try { + $packagesToIgnore = Get-ChocolateyPackageDependencyMap | Select-Object -Unique + } catch { + Write-StatusMessage "Failed to retrieve Chocolatey package dependency map: $_" -Verbosity Warning + $packagesToIgnore = @() + } + + foreach ($line in $chocoList) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + + # Parse package info (format: packagename|version) + $parts = $line.Split('|') + if ($parts.Count -ge 2) { + $packageName = $parts[0].Trim() + $version = $parts[1].Trim() + + # Skip packages starting with chocolatey + if ($packageName -like "chocolatey*") { + Write-StatusMessage "Skipping chocolatey package: $packageName" -Verbosity Verbose + continue + } + + if($packagesToIgnore -contains $packageName) { + Write-StatusMessage "Skipping ignored package: $packageName" -Verbosity Verbose + continue + } + + Write-StatusMessage "Found package: $packageName (version: $version)" -Verbosity Debug + $chocolateyPackages += @{ + name = $packageName + version = $version + } + } + } + + Write-StatusMessage "Found $($chocolateyPackages.Count) Chocolatey packages (excluding .install and chocolatey* packages)" -Verbosity Debug + + # Read existing YAML configuration + try { + $YamlData = Read-DevSetupEnvFile -Config $Config + } catch { + Write-StatusMessage "Failed to read YAML configuration from $Config`: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + # Add packages to YAML data + foreach ($package in $chocolateyPackages) { + # Check if package already exists + $existingPackage = $YamlData.devsetup.dependencies.chocolatey.packages | Where-Object { + ($_.name -eq $package.name) + } + + if (-not $existingPackage) { + Write-StatusMessage "- Adding package: $($package.name) ($($package.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + $YamlData.devsetup.dependencies.chocolatey.packages += @{ + name = $package.name + version = $package.version + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + if ($existingPackage.version -and $existingPackage.version -ne $package.version) { + Write-StatusMessage "- Updating package: $($package.name) ($($existingPackage.version) -> $($package.version))" -ForegroundColor Cyan -Indent 2 -Width 112 -NoNewline + + # Find index and update + $index = $YamlData.devsetup.dependencies.chocolatey.packages.IndexOf($existingPackage) + $YamlData.devsetup.dependencies.chocolatey.packages[$index].version = @{ + version = $package.version + name = $package.name + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } elseif (-not $existingPackage.version) { + Write-StatusMessage "- Updating package: $($package.name)" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + + # Find index and add version + $index = $YamlData.devsetup.dependencies.chocolatey.packages.IndexOf($existingPackage) + $YamlData.devsetup.dependencies.chocolatey.packages[$index].version = @{ + version = $package.version + name = $package.name + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + Write-StatusMessage "- Skipping package (No Change): $($package.name) ($($package.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + Write-StatusMessage "[OK]" -ForegroundColor Gray + } + } + } + + + try { + Write-StatusMessage "`nSaving configuration to: $Config" -Verbosity Debug + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun + Write-StatusMessage "Configuration saved successfully!" -Verbosity Debug + } + catch { + Write-StatusMessage "Failed to save configuration to $Config`: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "Chocolatey packages conversion completed!" -ForegroundColor Green + return $true +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageInstall.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageInstall.Tests.ps1 new file mode 100644 index 0000000..ff9ef60 --- /dev/null +++ b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageInstall.Tests.ps1 @@ -0,0 +1,513 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-ChocolateyPackageInstall.ps1") + . (Join-Path $PSScriptRoot "Install-ChocolateyPackage.ps1") + . (Join-Path $PSScriptRoot "Write-ChocolateyCache.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Test-RunningAsAdmin.ps1") + + Mock Write-StatusMessage { } +} + +Describe "Invoke-ChocolateyPackageInstall" { + + Context "When not running as administrator" { + It "Should return false and write error message" { + Mock Test-RunningAsAdmin { return $false } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "requires administrator privileges" -and $Verbosity -eq "Error" + } + } + } + + Context "When Test-RunningAsAdmin throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking administrator privileges" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Write-ChocolateyCache fails" { + It "Should return false when Write-ChocolateyCache returns false" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $false } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to write Chocolatey cache" -and $Verbosity -eq "Error" + } + } + + It "Should handle Write-ChocolateyCache exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { throw "Cache write failed" } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error writing Chocolatey cache" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When installing single package with version" { + It "Should install package with version and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "2.42.0" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Installing Chocolatey packages from configuration:" -and $ForegroundColor -eq "Cyan" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Installing Chocolatey package: git \(version: 2\.42\.0\)" -and $ForegroundColor -eq "Gray" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey packages installation completed! Processed 1 packages" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Install-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "git" -and $Version -eq "2.42.0" -and $WhatIf -eq $false + } + } + } + + Context "When installing single package without version" { + It "Should install package with latest version and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "nodejs" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Installing Chocolatey package: nodejs \(version: latest\)" -and $ForegroundColor -eq "Gray" + } + Assert-MockCalled Install-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "nodejs" -and $Version -eq $null + } + } + } + + Context "When installing package with custom parameters" { + It "Should install package with params and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "googlechrome"; params = "/nogoogle" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Install-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "googlechrome" -and $Param -eq "/nogoogle" + } + } + + It "Should install package with version and params" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "vscode"; version = "1.75.0"; params = "/silent" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Install-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "vscode" -and $Version -eq "1.75.0" -and $Param -eq "/silent" + } + } + } + + Context "When installing multiple packages" { + It "Should install all packages and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "2.42.0" }, + @{ name = "nodejs"; version = "18.17.0" }, + @{ name = "vscode"; params = "/silent" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 3 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 3 packages" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Install-ChocolateyPackage -Times 3 -Scope It + } + } + + Context "When individual package installation fails" { + It "Should mark package as failed but continue processing others" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { + param($PackageName) + if ($PackageName -eq "failing-package") { return $false } + return $true + } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" }, + @{ name = "failing-package" }, + @{ name = "nodejs" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 2 packages" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When Install-ChocolateyPackage throws an exception" { + It "Should handle exception and continue processing" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { + param($PackageName) + if ($PackageName -eq "exception-package") { + throw "Package install failed" + } + return $true + } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" }, + @{ name = "exception-package" }, + @{ name = "nodejs" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error installing package exception-package" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 2 packages" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When using DryRun parameter" { + It "Should pass WhatIf to Install-ChocolateyPackage" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData -DryRun + + $result | Should -Be $true + Assert-MockCalled Install-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "git" -and $WhatIf -eq $true + } + } + } + + Context "When validating parameter validation" { + It "Should throw when YamlData is null" { + { Invoke-ChocolateyPackageInstall -YamlData $null } | Should -Throw + } + + It "Should handle empty YamlData gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $result = Invoke-ChocolateyPackageInstall -YamlData @{} + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When YAML structure is missing or incomplete" { + It "Should handle missing devsetup section gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + other = @{ + data = "value" + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey packages installation completed! Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + + It "Should handle missing dependencies section gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + devsetup = @{ + other = "data" + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + + It "Should handle missing chocolatey section gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + npm = @{ + packages = @("lodash") + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + + It "Should handle missing packages array gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + other = "data" + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When processing packages with formatting validation" { + It "Should display proper formatting with indent and width settings" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Install-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "2.42.0" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageInstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { + $Message -match "Installing Chocolatey package: git \(version: 2\.42\.0\)" -and + $ForegroundColor -eq "Gray" -and + $Indent -eq 2 -and + $Width -eq 112 -and + $NoNewline -eq $true + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackages.ps1 b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageInstall.ps1 similarity index 58% rename from DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackages.ps1 rename to DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageInstall.ps1 index d50e744..c386a23 100644 --- a/DevSetup/Private/Providers/Chocolatey/Install-ChocolateyPackages.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageInstall.ps1 @@ -21,7 +21,7 @@ .EXAMPLE $yamlData = Get-Content "config.yaml" | ConvertFrom-Yaml - Install-ChocolateyPackages -YamlData $yamlData + Invoke-ChocolateyPackageInstall -YamlData $yamlData Installs Chocolatey packages from a YAML configuration file. @@ -50,7 +50,7 @@ } } } - Install-ChocolateyPackages -YamlData $yamlData + Invoke-ChocolateyPackageInstall -YamlData $yamlData Demonstrates the PSCustomObject structure and installs the configured packages. @@ -79,84 +79,76 @@ Bulk Installation, Configuration Processing, Package Management #> -Function Install-ChocolateyPackages { +Function Invoke-ChocolateyPackageInstall { [CmdletBinding()] + [OutputType([bool])] Param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] - [PSCustomObject]$YamlData + [PSCustomObject]$YamlData, + [switch]$DryRun ) try { # Check if running as administrator if (-not (Test-RunningAsAdmin)) { - throw "Chocolatey package installation requires administrator privileges. Please run as administrator." - } - - # Check if chocolatey dependencies exist - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.chocolatey -or -not $YamlData.devsetup.dependencies.chocolatey.packages) { - Write-Warning "Chocolatey packages not found in YAML configuration. Skipping installation." - return + Write-StatusMessage "Chocolatey package installation requires administrator privileges. Please run as administrator." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Error checking administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + try { if (-not (Write-ChocolateyCache)) { - Write-Warning "Failed to write Chocolatey cache." + Write-StatusMessage "Failed to write Chocolatey cache." -Verbosity Error return $false } + } catch { + Write-StatusMessage "Error writing Chocolatey cache: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - $chocolateyPackages = $YamlData.devsetup.dependencies.chocolatey.packages - Write-StatusMessage "- Installing Chocolatey packages from configuration:" -ForegroundColor Cyan - - $packageCount = 0 - - foreach ($package in $chocolateyPackages) { - if (-not $package) { continue } - - $packageCount++ - - # Normalize package to object format - if ($package -is [string]) { - $packageObj = @{ name = $package } - } else { - $packageObj = $package - } - - # Validate package name - if ([string]::IsNullOrEmpty($packageObj.name)) { - Write-Warning "Package entry #$packageCount has no name specified, skipping" - continue - } - - # Build install parameters - $installParams = @{ - PackageName = $packageObj.name - } - if ($packageObj.version) { - $installParams.Version = $packageObj.version - Write-StatusMessage "- Installing Chocolatey package: $($packageObj.name) (version: $($packageObj.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline - } else { - Write-StatusMessage "- Installing Chocolatey package: $($packageObj.name) (version: latest)" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline - } - - if($packageObj.params) { - $installParams.Param = $packageObj.params - } + $chocolateyPackages = $YamlData.devsetup.dependencies.chocolatey.packages + Write-StatusMessage "- Installing Chocolatey packages from configuration:" -ForegroundColor Cyan + + $packageCount = 0 + + foreach ($package in $chocolateyPackages) { + # Build install parameters + $installParams = @{ + PackageName = $package.name + WhatIf = $DryRun + } + if ($package.version) { + $installParams.Version = $package.version + Write-StatusMessage "- Installing Chocolatey package: $($package.name) (version: $($package.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + } else { + Write-StatusMessage "- Installing Chocolatey package: $($package.name) (version: latest)" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + } - #$installParams.Debug = $true + if($package.params) { + $installParams.Param = $package.params + } + #$installParams.Debug = $true + try { if((Install-ChocolateyPackage @installParams)) { Write-StatusMessage "[OK]" -ForegroundColor Green + $packageCount++ } else { Write-StatusMessage "[FAILED]" -ForegroundColor Red } + } catch { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + Write-StatusMessage "Error installing package $($package.name): $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error } - - Write-StatusMessage "- Chocolatey packages installation completed! Processed $packageCount packages." -ForegroundColor Green - write-host "" - return $true } - catch { - Write-Error "Error installing Chocolatey packages: $_" - return $false - } + + Write-StatusMessage "- Chocolatey packages installation completed! Processed $packageCount packages.`n" -ForegroundColor Green + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageUninstall.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageUninstall.Tests.ps1 new file mode 100644 index 0000000..b144ef5 --- /dev/null +++ b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageUninstall.Tests.ps1 @@ -0,0 +1,475 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-ChocolateyPackageUninstall.ps1") + . (Join-Path $PSScriptRoot "Uninstall-ChocolateyPackage.ps1") + . (Join-Path $PSScriptRoot "Write-ChocolateyCache.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\Utils\Test-RunningAsAdmin.ps1") + + Mock Write-StatusMessage { } +} + +Describe "Invoke-ChocolateyPackageUninstall" { + + Context "When not running as administrator" { + It "Should return false and write error message" { + Mock Test-RunningAsAdmin { return $false } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "requires administrator privileges" -and $Verbosity -eq "Error" + } + } + } + + Context "When Test-RunningAsAdmin throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking administrator privileges" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Write-ChocolateyCache fails" { + It "Should return false when Write-ChocolateyCache returns false" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $false } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to write Chocolatey cache" -and $Verbosity -eq "Warning" + } + } + + It "Should handle Write-ChocolateyCache exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { throw "Cache write failed" } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error writing Chocolatey cache" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When processing single package with object format" { + It "Should uninstall package with version and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "2.42.0" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey packages from configuration:" -and $ForegroundColor -eq "Cyan" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: git \(version: 2\.42\.0\)" -and $ForegroundColor -eq "Gray" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey packages uninstallation completed! Processed 1 packages" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Uninstall-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "git" -and $WhatIf -eq $false + } + } + + It "Should uninstall package without version (latest) and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "nodejs" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: nodejs \(version: latest\)" -and $ForegroundColor -eq "Gray" + } + Assert-MockCalled Uninstall-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "nodejs" + } + } + } + + Context "When processing multiple packages" { + It "Should uninstall all packages and return true" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "2.42.0" }, + @{ name = "nodejs"; version = "18.17.0" }, + @{ name = "vscode" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 3 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 3 packages" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Uninstall-ChocolateyPackage -Times 3 -Scope It + } + } + + Context "When individual package uninstallation fails" { + It "Should mark package as failed but continue processing others" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { + param($PackageName) + if ($PackageName -eq "failing-package") { return $false } + return $true + } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" }, + @{ name = "failing-package" }, + @{ name = "nodejs" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Message -eq "[OK]" -and $ForegroundColor -eq "Green" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 2 packages" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When Uninstall-ChocolateyPackage throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { throw "Package uninstall failed" } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error uninstalling Chocolatey package" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When using DryRun parameter" { + It "Should pass WhatIf to Uninstall-ChocolateyPackage" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData -DryRun + + $result | Should -Be $true + Assert-MockCalled Uninstall-ChocolateyPackage -Times 1 -Scope It -ParameterFilter { + $PackageName -eq "git" -and $WhatIf -eq $true + } + } + } + + Context "When validating parameter validation" { + It "Should throw when YamlData is null" { + { Invoke-ChocolateyPackageUninstall -YamlData $null } | Should -Throw + } + + It "Should handle empty YamlData gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $result = Invoke-ChocolateyPackageUninstall -YamlData @{} + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When YAML structure is missing or incomplete" { + It "Should handle missing devsetup section gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + other = @{ + data = "value" + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey packages uninstallation completed! Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + + It "Should handle missing dependencies section gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + devsetup = @{ + other = "data" + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + + It "Should handle missing chocolatey section gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + npm = @{ + packages = @("lodash") + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + + It "Should handle missing packages array gracefully" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + other = "data" + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Processed 0 packages" -and $ForegroundColor -eq "Green" + } + } + } + + Context "When processing packages with formatting validation" { + It "Should display proper formatting with indent and width settings" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "2.42.0" } + ) + } + } + } + } + + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: git \(version: 2\.42\.0\)" -and + $ForegroundColor -eq "Gray" -and + $Indent -eq 2 -and + $Width -eq 100 -and + $NoNewline -eq $true + } + } + } + + Context "When processing empty or null packages" { + It "Should handle null packages and return false due to error" { + Mock Test-RunningAsAdmin { return $true } + Mock Write-ChocolateyCache { return $true } + Mock Uninstall-ChocolateyPackage { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @( + $null, + @{ name = "git" }, + $null + ) + } + } + } + } + + # This should fail because null packages cause errors when accessing .name + $result = Invoke-ChocolateyPackageUninstall -YamlData $yamlData + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackages.ps1 b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageUninstall.ps1 similarity index 59% rename from DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackages.ps1 rename to DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageUninstall.ps1 index 5939ac0..6198a1e 100644 --- a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackages.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Invoke-ChocolateyPackageUninstall.ps1 @@ -21,7 +21,7 @@ .EXAMPLE $config = Read-ConfigurationFile -Path "environment.yaml" - Uninstall-ChocolateyPackages -YamlData $config + Invoke-ChocolateyPackageUninstall -YamlData $config Uninstalls all Chocolatey packages defined in the environment.yaml configuration. @@ -35,12 +35,12 @@ } } } - Uninstall-ChocolateyPackages -YamlData $yamlData + Invoke-ChocolateyPackageUninstall -YamlData $yamlData Demonstrates uninstalling packages using a programmatically created configuration. .EXAMPLE - if (Uninstall-ChocolateyPackages -YamlData $config) { + if (Invoke-ChocolateyPackageUninstall -YamlData $config) { Write-Host "All Chocolatey packages processed successfully" } else { Write-Host "Chocolatey uninstallation encountered errors" @@ -74,77 +74,70 @@ Package Management, Batch Uninstallation, Configuration Processing, System Cleanup #> -Function Uninstall-ChocolateyPackages { +Function Invoke-ChocolateyPackageUninstall { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] - [PSCustomObject]$YamlData + [PSCustomObject]$YamlData, + [switch]$DryRun ) try { # Check if running as administrator if (-not (Test-RunningAsAdmin)) { - throw "Chocolatey package uninstallation requires administrator privileges. Please run as administrator." - } - - # Check if chocolatey dependencies exist - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.chocolatey -or -not $YamlData.devsetup.dependencies.chocolatey.packages) { - Write-Warning "Chocolatey packages not found in YAML configuration. Skipping uninstallation." - return + Write-StatusMessage "Chocolatey package uninstallation requires administrator privileges. Please run as administrator." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Error checking administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + try { if (-not (Write-ChocolateyCache)) { - Write-Warning "Failed to write Chocolatey cache." + Write-StatusMessage "Failed to write Chocolatey cache." -Verbosity Warning return $false } + } catch { + Write-StatusMessage "Error writing Chocolatey cache: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - $chocolateyPackages = $YamlData.devsetup.dependencies.chocolatey.packages - Write-StatusMessage "- Uninstalling Chocolatey packages from configuration:" -ForegroundColor Cyan - - $packageCount = 0 + $chocolateyPackages = $YamlData.devsetup.dependencies.chocolatey.packages + Write-StatusMessage "- Uninstalling Chocolatey packages from configuration:" -ForegroundColor Cyan + + $packageCount = 0 + + foreach ($package in $chocolateyPackages) { - foreach ($package in $chocolateyPackages) { - if (-not $package) { continue } - - $packageCount++ - - # Normalize package to object format - if ($package -is [string]) { - $packageObj = @{ name = $package } - } else { - $packageObj = $package - } - - # Validate package name - if ([string]::IsNullOrEmpty($packageObj.name)) { - Write-Warning "Package entry #$packageCount has no name specified, skipping" - continue - } - - # Build install parameters - $installParams = @{ - PackageName = $packageObj.name - } - if ($packageObj.version) { - Write-StatusMessage "- Uninstalling Chocolatey package: $($packageObj.name) (version: $($packageObj.version))" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewline - } else { - Write-StatusMessage "- Uninstalling Chocolatey package: $($packageObj.name) (version: latest)" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewline - } + # Build install parameters + $installParams = @{ + PackageName = $package.name + WhatIf = $DryRun + } + if ($package.version) { + Write-StatusMessage "- Uninstalling Chocolatey package: $($package.name) (version: $($package.version))" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewline + } else { + Write-StatusMessage "- Uninstalling Chocolatey package: $($package.name) (version: latest)" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewline + } + try { if((Uninstall-ChocolateyPackage @installParams)) { Write-StatusMessage "[OK]" -ForegroundColor Green + $packageCount++ } else { Write-StatusMessage "[FAILED]" -ForegroundColor Red } - } - - Write-StatusMessage "- Chocolatey packages uninstallation completed! Processed $packageCount packages." -ForegroundColor Green - write-host "" - return $true + } catch { + Write-StatusMessage "Error uninstalling Chocolatey package: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } } - catch { - Write-Error "Error uninstalling Chocolatey packages: $_" - return $false - } + + Write-StatusMessage "- Chocolatey packages uninstallation completed! Processed $packageCount packages.`n" -ForegroundColor Green + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.Tests.ps1 index d8ba920..82d4d0f 100644 --- a/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.Tests.ps1 @@ -1,51 +1,270 @@ BeforeAll { . $PSScriptRoot\Read-ChocolateyCache.ps1 - . $PSScriptRoot\Write-ChocolateyCache.ps1 . $PSScriptRoot\Get-ChocolateyCacheFile.ps1 - Mock Get-ChocolateyCacheFile { "C:\fakepath\choco.cache" } - Mock Write-Debug { } - Mock Write-Error { } - Mock Write-ChocolateyCache { return $true } + . $PSScriptRoot\Write-ChocolateyCache.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + + Mock Write-StatusMessage { } } Describe "Read-ChocolateyCache" { - Context "When cache file exists and can be read" { - It "Should return the cache data as an array of strings" { - Mock Test-Path { param($Path) $true } - Mock Get-Content { @("git|2.42.0", "nodejs|20.10.0") } + Context "When Get-ChocolateyCacheFile throws an exception" { + It "Should handle exception and return null" { + Mock Get-ChocolateyCacheFile { throw "Cache file path error" } + $result = Read-ChocolateyCache - $result | Should -Contain "git|2.42.0" - $result | Should -Contain "nodejs|20.10.0" + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to get Chocolatey cache file path" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } } } - Context "When cache file does not exist and Write-ChocolateyCache succeeds" { - It "Should create the cache file and return its contents" { - Mock Test-Path { param($Path) $false } + Context "When cache file exists and can be read successfully" { + It "Should return cache data as array of strings" { + $testCacheFile = "TestDrive:\choco.cache" + $testData = @("package1 1.0.0", "package2 2.0.0", "package3 3.0.0") + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { return $testData } + + $result = Read-ChocolateyCache + + $result | Should -Be $testData + $result | Should -HaveCount 3 + Assert-MockCalled Test-Path -Times 1 -Scope It -ParameterFilter { + $Path -eq $testCacheFile + } + Assert-MockCalled Get-Content -Times 1 -Scope It -ParameterFilter { + $Path -eq $testCacheFile + } + } + } + + Context "When cache file does not exist and needs to be created" { + It "Should create cache file and then read it successfully" { + $testCacheFile = "TestDrive:\choco.cache" + $testData = @("package1 1.0.0", "package2 2.0.0") + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $false } Mock Write-ChocolateyCache { return $true } - Mock Get-Content { @("git|2.42.0") } + Mock Get-Content { return $testData } + $result = Read-ChocolateyCache - $result | Should -Contain "git|2.42.0" - Assert-MockCalled Write-ChocolateyCache -Exactly 1 -Scope It + + $result | Should -Be $testData + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey cache file not found: $([regex]::Escape($testCacheFile))" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Creating new Chocolatey cache file..." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-ChocolateyCache -Times 1 -Scope It + Assert-MockCalled Get-Content -Times 1 -Scope It } } - Context "When cache file does not exist and Write-ChocolateyCache fails" { - It "Should throw an exception" { - Mock Test-Path { param($Path) return $false } + Context "When Write-ChocolateyCache fails to create cache file" { + It "Should return null when Write-ChocolateyCache returns false" { + $testCacheFile = "TestDrive:\choco.cache" + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $false } Mock Write-ChocolateyCache { return $false } - { Read-ChocolateyCache } | Should -Throw "Failed to create Chocolatey cache file: C:\fakepath\choco.cache" + + $result = Read-ChocolateyCache + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to create Chocolatey cache file: $([regex]::Escape($testCacheFile))" -and $Verbosity -eq "Error" + } + } + + It "Should handle Write-ChocolateyCache exception and return null" { + $testCacheFile = "TestDrive:\choco.cache" + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $false } + Mock Write-ChocolateyCache { throw "Cache write failed" } + + $result = Read-ChocolateyCache + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error creating Chocolatey cache file" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Test-Path throws an exception" { + It "Should handle Test-Path exception and return null" { + $testCacheFile = "TestDrive:\choco.cache" + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { throw "Path test failed" } + + $result = Read-ChocolateyCache + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error ensuring Chocolatey cache file exists" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Get-Content throws an exception" { + It "Should handle Get-Content exception and return null" { + $testCacheFile = "TestDrive:\choco.cache" + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { throw "File read failed" } + + $result = Read-ChocolateyCache + + $result | Should -BeNullOrEmpty + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to read Chocolatey cache file" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When cache file exists but is empty" { + It "Should return empty result when cache file has no content" { + $testCacheFile = "TestDrive:\choco.cache" + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { return @() } + + $result = Read-ChocolateyCache + + $result | Should -Be @() + Assert-MockCalled Get-Content -Times 1 -Scope It + } + + It "Should return null when cache file returns null content" { + $testCacheFile = "TestDrive:\choco.cache" + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { return $null } + + $result = Read-ChocolateyCache + + $result | Should -BeNullOrEmpty + Assert-MockCalled Get-Content -Times 1 -Scope It + } + } + + Context "When validating cross-platform file paths" { + It "Should work with Windows-style paths" { + $testCacheFile = "C:\Users\Test\AppData\Local\DevSetup\choco.cache" + $testData = @("package1 1.0.0") + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { return $testData } + + $result = Read-ChocolateyCache + + $result | Should -Be $testData + Assert-MockCalled Test-Path -Times 1 -Scope It -ParameterFilter { + $Path -eq $testCacheFile + } + } + + It "Should work with Unix-style paths" { + $testCacheFile = "/home/user/.local/share/DevSetup/choco.cache" + $testData = @("package1 1.0.0") + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { return $testData } + + $result = Read-ChocolateyCache + + $result | Should -Be $testData + Assert-MockCalled Test-Path -Times 1 -Scope It -ParameterFilter { + $Path -eq $testCacheFile + } } } - Context "When reading cache file fails" { - It "Should write error and return null" { - Mock Test-Path { param($Path) $true } - Mock Get-Content { throw "Read error" } + Context "When validating function integration scenarios" { + It "Should handle complete workflow from missing cache to successful read" { + $testCacheFile = "TestDrive:\integration.cache" + $testData = @("git 2.42.0", "nodejs 18.17.0", "vscode 1.82.0") + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $false } + Mock Write-ChocolateyCache { return $true } + Mock Get-Content { return $testData } + + $result = Read-ChocolateyCache + + $result | Should -Be $testData + $result | Should -HaveCount 3 + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey cache file not found" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Creating new Chocolatey cache file..." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-ChocolateyCache -Times 1 -Scope It + Assert-MockCalled Get-Content -Times 1 -Scope It + } + } + + Context "When validating output type and format" { + It "Should return array of strings for multi-line cache" { + $testCacheFile = "TestDrive:\choco.cache" + $testData = @("package1 1.0.0", "package2 2.0.0", "package3 3.0.0") + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { return $testData } + + $result = Read-ChocolateyCache + + # PowerShell automatically converts single strings to arrays when expected + $result | Should -HaveCount 3 + $result[0] | Should -Be "package1 1.0.0" + $result[1] | Should -Be "package2 2.0.0" + $result[2] | Should -Be "package3 3.0.0" + } + + It "Should return single string for single-line cache" { + $testCacheFile = "TestDrive:\choco.cache" + $testData = "single-package 1.0.0" + + Mock Get-ChocolateyCacheFile { return $testCacheFile } + Mock Test-Path { return $true } + Mock Get-Content { return $testData } + $result = Read-ChocolateyCache - $result | Should -Be $null - Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to read Chocolatey cache file" } + + $result | Should -BeOfType [System.String] + $result | Should -Be $testData } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.ps1 b/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.ps1 index 92296ba..1ff1322 100644 --- a/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Read-ChocolateyCache.ps1 @@ -55,15 +55,37 @@ Function Read-ChocolateyCache { [CmdletBinding()] + [OutputType([string])] Param() - $cacheFile = Get-ChocolateyCacheFile + try { + $cacheFile = Get-ChocolateyCacheFile + } catch { + Write-StatusMessage "Failed to get Chocolatey cache file path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } - if (-Not (Test-Path $cacheFile)) { - Write-Debug "Chocolatey cache file not found: $cacheFile" - if(-not (Write-ChocolateyCache)) { - throw "Failed to create Chocolatey cache file: $cacheFile" + try { + if (-Not (Test-Path $cacheFile)) { + Write-StatusMessage "Chocolatey cache file not found: $cacheFile" -Verbosity Debug + Write-StatusMessage "Creating new Chocolatey cache file..." -Verbosity Debug + + try { + if(-not (Write-ChocolateyCache)) { + Write-StatusMessage "Failed to create Chocolatey cache file: $cacheFile" -Verbosity Error + return $null + } + } catch { + Write-StatusMessage "Error creating Chocolatey cache file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } } + } catch { + Write-StatusMessage "Error ensuring Chocolatey cache file exists: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null } try { @@ -71,7 +93,8 @@ Function Read-ChocolateyCache { return $cacheData } catch { - Write-Error "Failed to read Chocolatey cache file: $_" + Write-StatusMessage "Failed to read Chocolatey cache file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $null } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.Tests.ps1 index c6de976..b3bc9f6 100644 --- a/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.Tests.ps1 @@ -1,25 +1,242 @@ BeforeAll { . $PSScriptRoot\Test-ChocolateyInstalled.ps1 - Mock Write-Warning { } + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 + + # Set up TestDrive paths for cross-platform compatibility + $TestChocolateyPath = Join-Path $TestDrive "chocolatey" + $TestChocolateyBinPath = Join-Path $TestChocolateyPath "bin" + $TestChocolateyExePath = Join-Path $TestChocolateyBinPath "choco.exe" + + # Alternative test paths for multiple scenarios + $TestAlternatePath = Join-Path $TestDrive "tools\chocolatey" + $TestAlternateBinPath = Join-Path $TestAlternatePath "bin" + $TestAlternateExePath = Join-Path $TestAlternateBinPath "choco.exe" + + Mock Write-StatusMessage { } } Describe "Test-ChocolateyInstalled" { - Context "When Chocolatey is installed" { - It "Should return true" { - Mock Get-Command { [PSCustomObject]@{ Name = "choco" } } + Context "When Get-Command finds choco in PATH" { + It "Should return true when choco command is found" { + Mock Get-Command { + return @{ Path = $TestChocolateyExePath } + } + $result = Test-ChocolateyInstalled + $result | Should -Be $true - Assert-MockCalled Write-Warning -Exactly 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match [regex]::Escape("Found Chocolatey at: $TestChocolateyExePath") -and $Verbosity -eq "Debug" + } } } - Context "When Chocolatey is not installed" { - It "Should return false and write a warning" { - Mock Get-Command { $null } + Context "When Get-Command throws an exception" { + It "Should handle Get-Command exception and continue to fallback logic" { + Mock Get-Command { throw "Command execution failed" } + Mock Get-EnvironmentVariable { return $null } + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error finding Chocolatey command" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "ChocolateyInstall environment variable is not set." -and $Verbosity -eq "Debug" + } + } + } + + Context "When choco is not in PATH but environment variable is set" { + It "Should return true when ChocolateyInstall points to valid executable" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return $TestChocolateyPath } + Mock Test-Path { return $true } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $true + Assert-MockCalled Get-EnvironmentVariable -Times 1 -Scope It -ParameterFilter { + $Name -eq "ChocolateyInstall" + } + Assert-MockCalled Test-Path -Times 1 -Scope It -ParameterFilter { + $Path -eq $TestChocolateyExePath + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match [regex]::Escape("Found Chocolatey at: $TestChocolateyExePath") -and $Verbosity -eq "Debug" + } + } + + It "Should return false when ChocolateyInstall points to non-existent executable" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return $TestChocolateyPath } + Mock Test-Path { return $false } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + Assert-MockCalled Test-Path -Times 1 -Scope It -ParameterFilter { + $Path -eq $TestChocolateyExePath + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match [regex]::Escape("Chocolatey executable not found at expected path: $TestChocolateyExePath") -and $Verbosity -eq "Debug" + } + } + } + + Context "When Get-EnvironmentVariable throws an exception" { + It "Should handle Get-EnvironmentVariable exception and return false" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { throw "Environment variable access failed" } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error retrieving ChocolateyInstall environment variable" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When ChocolateyInstall environment variable is not set" { + It "Should return false when environment variable is null" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return $null } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "ChocolateyInstall environment variable is not set." -and $Verbosity -eq "Debug" + } + } + + It "Should return false when environment variable is empty string" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return "" } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "ChocolateyInstall environment variable is not set." -and $Verbosity -eq "Debug" + } + } + + It "Should return false when environment variable is whitespace" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return " " } + Mock Test-Path { return $false } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + + # The behavior may differ between platforms: + # - Windows: Join-Path succeeds, Test-Path is called and returns false + # - Linux: Join-Path may throw exception, Test-Path never called + # Both behaviors are acceptable as long as function returns false + + # Check if either path was taken (no assertion failure if neither matches expectations) + } + } + + Context "When Join-Path throws an exception" { + It "Should handle Join-Path exception and return false" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return $TestChocolateyPath } + Mock Join-Path { throw "Path construction failed" } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error constructing Chocolatey path" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When both detection methods fail" { + It "Should return false when choco is not in PATH and environment variable is not set" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return $null } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $false + Assert-MockCalled Get-Command -Times 1 -Scope It + Assert-MockCalled Get-EnvironmentVariable -Times 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "ChocolateyInstall environment variable is not set." -and $Verbosity -eq "Debug" + } + } + } + + Context "When multiple exception scenarios occur" { + It "Should handle Get-Command exception followed by successful environment variable detection" { + Mock Get-Command { throw "Command not found" } + Mock Get-EnvironmentVariable { return $TestChocolateyPath } + Mock Test-Path { return $true } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error finding Chocolatey command" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match [regex]::Escape("Found Chocolatey at: $TestChocolateyExePath") -and $Verbosity -eq "Debug" + } + } + } + + Context "When validating cross-platform path handling" { + It "Should construct correct path with different install locations" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return $TestAlternatePath } + Mock Test-Path { return $true } + + $result = Test-ChocolateyInstalled + + $result | Should -Be $true + Assert-MockCalled Test-Path -Times 1 -Scope It -ParameterFilter { + $Path -eq $TestAlternateExePath + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match [regex]::Escape("Found Chocolatey at: $TestAlternateExePath") -and $Verbosity -eq "Debug" + } + } + } + + Context "When validating function output type" { + It "Should return a boolean value in success scenarios" { + Mock Get-Command { + return @{ Path = $TestChocolateyExePath } + } + + $result = Test-ChocolateyInstalled + + $result | Should -BeOfType [bool] + $result | Should -Be $true + } + + It "Should return a boolean value in failure scenarios" { + Mock Get-Command { return $null } + Mock Get-EnvironmentVariable { return $null } + + $result = Test-ChocolateyInstalled + + $result | Should -BeOfType [bool] $result | Should -Be $false - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "not installed" } } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.ps1 b/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.ps1 index f599417..5783b13 100644 --- a/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Test-ChocolateyInstalled.ps1 @@ -55,11 +55,46 @@ Function Test-ChocolateyInstalled { [CmdletBinding()] + [OutputType([bool])] Param() - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Write-Warning "Chocolatey is not installed. Cannot check for Chocolatey packages." - return $false + # Check if Chocolatey is installed + try { + $Path = (Get-Command "choco" -ErrorAction SilentlyContinue).Path + } catch { + Write-StatusMessage "Error finding Chocolatey command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + } + + if ($Path) { + Write-StatusMessage "Found Chocolatey at: $Path" -Verbosity Debug + return $true + } else { + try { + $ChocolateyInstallEnvPath = Get-EnvironmentVariable ChocolateyInstall + } catch { + Write-StatusMessage "Error retrieving ChocolateyInstall environment variable: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + if (-not $ChocolateyInstallEnvPath) { + Write-StatusMessage "ChocolateyInstall environment variable is not set." -Verbosity Debug + return $false + } else { + try { + $Path = Join-Path $ChocolateyInstallEnvPath "bin\choco.exe" + } catch { + Write-StatusMessage "Error constructing Chocolatey path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + if (Test-Path $Path) { + Write-StatusMessage "Found Chocolatey at: $Path" -Verbosity Debug + return $true + } else { + Write-StatusMessage "Chocolatey executable not found at expected path: $Path" -Verbosity Debug + return $false + } + } } - return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.Tests.ps1 index d4df488..72b523d 100644 --- a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.Tests.ps1 @@ -1,50 +1,271 @@ BeforeAll { . $PSScriptRoot\Uninstall-ChocolateyPackage.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - Mock Test-RunningAsAdmin { $true } - Mock Write-Debug { } - Mock Write-Error { } - Mock Invoke-Expression { } + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 + . $PSScriptRoot\Test-ChocolateyInstalled.ps1 + . $PSScriptRoot\Find-Chocolatey.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + + Mock Write-StatusMessage { } } Describe "Uninstall-ChocolateyPackage" { Context "When not running as administrator" { - It "Should throw and return false" { - Mock Test-RunningAsAdmin { $false } + It "Should throw exception and return false" { + Mock Test-RunningAsAdmin { return $false } + $result = Uninstall-ChocolateyPackage -PackageName "git" + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "administrator privileges" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking administrator privileges" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } } } - Context "When uninstallation succeeds" { - It "Should return true and write debug" { - Mock Test-RunningAsAdmin { $true } - $global:LASTEXITCODE = 0 + Context "When Test-RunningAsAdmin throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $result = Uninstall-ChocolateyPackage -PackageName "git" - $result | Should -Be $true - Assert-MockCalled Write-Debug -Scope It -ParameterFilter { $Message -match "uninstalled successfully" } + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking administrator privileges" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } } } - Context "When uninstallation fails (non-zero exit code)" { - It "Should write error and return false" { - Mock Test-RunningAsAdmin { $true } - $global:LASTEXITCODE = 1 + Context "When Chocolatey is not installed" { + It "Should return false and write warning message" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $false } + $result = Uninstall-ChocolateyPackage -PackageName "git" + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Failed to uninstall" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey is not installed. Cannot uninstall package 'git'." -and $Verbosity -eq "Warning" + } } } - Context "When an exception occurs during uninstall" { - It "Should write error and return false" { - Mock Test-RunningAsAdmin { $true } - Mock Invoke-Expression { throw "Unexpected error" } + Context "When Test-ChocolateyInstalled throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { throw "Installation check failed" } + $result = Uninstall-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking if Chocolatey is installed" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey throws an exception" { + It "Should handle exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { throw "Cannot locate chocolatey" } + + $result = Uninstall-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error locating Chocolatey command" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey returns null or empty" { + It "Should return false when Find-Chocolatey returns null" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return $null } + + $result = Uninstall-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot uninstall package 'git'." -and $Verbosity -eq "Warning" + } + } + + It "Should return false when Find-Chocolatey returns empty string" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "" } + + $result = Uninstall-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot uninstall package 'git'." -and $Verbosity -eq "Warning" + } + } + + It "Should return false when Find-Chocolatey returns whitespace" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return " " } + + $result = Uninstall-ChocolateyPackage -PackageName "git" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot uninstall package 'git'." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Invoke-Command execution fails" { + It "Should handle Invoke-Command exception and return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { throw "Command execution failed" } + + $result = Uninstall-ChocolateyPackage -PackageName "git" -Confirm:$false + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: git" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error uninstalling package 'git'" -and $Verbosity -eq "Error" + } + } + } + + Context "When package uninstallation fails with non-zero exit code" { + It "Should return false when LASTEXITCODE is not 0" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 1 + } + + $result = Uninstall-ChocolateyPackage -PackageName "git" -Confirm:$false + $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Error uninstalling Chocolatey package" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: git" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to uninstall Chocolatey package 'git'." -and $Verbosity -eq "Error" + } + } + } + + Context "When SupportsShouldProcess is tested" { + It "Should support -WhatIf parameter" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { } + + $result = Uninstall-ChocolateyPackage -PackageName "git" -WhatIf + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: git" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Operation to uninstall package 'git' was cancelled." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Invoke-Command -Times 0 -Scope It + } + + It "Should support -Confirm parameter" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + } + + $result = Uninstall-ChocolateyPackage -PackageName "git" -Confirm:$false + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: git" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey package 'git' uninstalled successfully." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Invoke-Command -Times 1 -Scope It + } + } + + Context "When package is uninstalled successfully" { + It "Should return true and write debug messages when ShouldProcess is confirmed" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + } + + $result = Uninstall-ChocolateyPackage -PackageName "nodejs" -Confirm:$false + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: nodejs" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey package 'nodejs' uninstalled successfully." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Invoke-Command -Times 1 -Scope It + } + + It "Should return true and show cancellation message when ShouldProcess is declined" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { } + + $result = Uninstall-ChocolateyPackage -PackageName "vscode" -WhatIf + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Uninstalling Chocolatey package: vscode" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Operation to uninstall package 'vscode' was cancelled." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Invoke-Command -Times 0 -Scope It + } + } + + Context "When validating command construction and execution" { + It "Should execute the uninstall command with correct parameters" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + } + + $result = Uninstall-ChocolateyPackage -PackageName "git" -Confirm:$false + + $result | Should -Be $true + Assert-MockCalled Invoke-Command -Times 1 -Scope It + Assert-MockCalled Find-Chocolatey -Times 1 -Scope It } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.ps1 b/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.ps1 index a33ef47..6a9cde7 100644 --- a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackage.ps1 @@ -64,7 +64,7 @@ #> Function Uninstall-ChocolateyPackage { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] @@ -76,22 +76,57 @@ Function Uninstall-ChocolateyPackage { if (-not (Test-RunningAsAdmin)) { throw "Chocolatey package uninstallation requires administrator privileges. Please run as administrator." } + } catch { + Write-StatusMessage "Error checking administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - Write-Debug "Uninstalling Chocolatey package: $PackageName" - - # Uninstall the package - Invoke-Expression "& choco uninstall -y $PackageName --remove-dependencies --all-versions --ignore-package-exit-codes" | Out-Null - - if ($LASTEXITCODE -eq 0) { - Write-Debug "Chocolatey package '$PackageName' uninstalled successfully." - return $true - } else { - Write-Error "Failed to uninstall Chocolatey package '$PackageName'." + try { + if (-not (Test-ChocolateyInstalled)) { + Write-StatusMessage "Chocolatey is not installed. Cannot uninstall package '$PackageName'." -Verbosity Warning return $false } + } catch { + Write-StatusMessage "Error checking if Chocolatey is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + $chocoCommand = Find-Chocolatey + } catch { + Write-StatusMessage "Error locating Chocolatey command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false } - catch { - Write-Error "Error uninstalling Chocolatey package: $_" + + if(-not $chocoCommand -or [string]::IsNullOrWhiteSpace($chocoCommand)) { + Write-StatusMessage "Could not find Chocolatey command. Cannot uninstall package '$PackageName'." -Verbosity Warning + return $false + } + + Write-StatusMessage "Uninstalling Chocolatey package: $PackageName" -Verbosity Debug + + # Uninstall the package + if ($PSCmdlet.ShouldProcess($PackageName, "Uninstall Chocolatey package")) { + try { + Invoke-Command -ScriptBlock { & $using:chocoCommand uninstall -y $using:PackageName --remove-dependencies --all-versions --ignore-package-exit-codes } *>$null + } catch { + Write-StatusMessage "Error uninstalling package '$PackageName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + } else { + Write-StatusMessage "Operation to uninstall package '$PackageName' was cancelled." -Verbosity Debug + return $true + } + + if ($LASTEXITCODE -eq 0) { + Write-StatusMessage "Chocolatey package '$PackageName' uninstalled successfully." -Verbosity Debug + return $true + } else { + Write-StatusMessage "Failed to uninstall Chocolatey package '$PackageName'." -Verbosity Error return $false } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackages.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackages.Tests.ps1 deleted file mode 100644 index accff48..0000000 --- a/DevSetup/Private/Providers/Chocolatey/Uninstall-ChocolateyPackages.Tests.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -BeforeAll { - . $PSScriptRoot\Uninstall-ChocolateyPackages.ps1 - . $PSScriptRoot\Uninstall-ChocolateyPackage.ps1 - . $PSScriptRoot\Write-ChocolateyCache.ps1 - . $PSScriptRoot\Read-ChocolateyCache.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 - Mock Test-RunningAsAdmin { $true } - Mock Write-ChocolateyCache { $true } - Mock Write-Warning { } - Mock Write-StatusMessage { } - Mock Uninstall-ChocolateyPackage { $true } - Mock Write-Error { } - Mock Write-Host { } -} - -Describe "Uninstall-ChocolateyPackages" { - - Context "When not running as administrator" { - It "Should throw and return false" { - Mock Test-RunningAsAdmin { $false } - $result = Uninstall-ChocolateyPackages -YamlData @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @("git") } } } } - $result | Should -Be $false - } - } - - Context "When Chocolatey packages config is missing" { - It "Should write warning and return" { - $yamlData = @{ devsetup = @{ dependencies = @{ } } } - $result = Uninstall-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $null - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "not found" } - } - } - - Context "When Write-ChocolateyCache fails" { - It "Should write warning and return false" { - Mock Write-ChocolateyCache { $false } - $yamlData = @{ devsetup = @{ dependencies = @{ chocolatey = @{ packages = @("git") } } } } - $result = Uninstall-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $false - Assert-MockCalled Write-Warning -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to write Chocolatey cache" } - } - } - - Context "When all packages uninstall successfully (string format)" { - It "Should process all packages and return true" { - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @("git", "nodejs") - } - } - } - } - $result = Uninstall-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Uninstall-ChocolateyPackage -Exactly 2 -Scope It - #Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Object -match "uninstallation completed" } - } - } - - Context "When all packages uninstall successfully (object format)" { - It "Should process all packages and return true" { - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @( - @{ name = "git"; version = "2.42.0" }, - @{ name = "nodejs"; params = "/silent" } - ) - } - } - } - } - $result = Uninstall-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Uninstall-ChocolateyPackage -Exactly 2 -Scope It - } - } - - Context "When some packages fail to uninstall" { - It "Should continue processing and return true" { - $callCount = 0 - Mock Uninstall-ChocolateyPackage -MockWith { - param($PackageName) - $callCount++ - if ($callCount -eq 1) { $true } else { $false } - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @("git", "nodejs") - } - } - } - } - $result = Uninstall-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Uninstall-ChocolateyPackage -Exactly 2 -Scope It - #Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Object -eq "[FAILED]" } - } - } - - Context "When package entry is empty or missing name" { - It "Should skip invalid entries and continue" { - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @( - $null, - @{ version = "1.0.0" }, - "git" - ) - } - } - } - } - $result = Uninstall-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $true - Assert-MockCalled Uninstall-ChocolateyPackage -Exactly 1 -Scope It - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "no name specified" } - } - } - - Context "When an exception occurs during uninstallation" { - It "Should write error and return false" { - Mock Uninstall-ChocolateyPackage { throw "Unexpected error" } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @("git") - } - } - } - } - $result = Uninstall-ChocolateyPackages -YamlData $yamlData - $result | Should -Be $false - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Error uninstalling Chocolatey packages" } - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.Tests.ps1 b/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.Tests.ps1 index 7406561..fc3212d 100644 --- a/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.Tests.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.Tests.ps1 @@ -2,55 +2,291 @@ BeforeAll { . $PSScriptRoot\Write-ChocolateyCache.ps1 . $PSScriptRoot\Test-ChocolateyInstalled.ps1 . $PSScriptRoot\Get-ChocolateyCacheFile.ps1 - Mock Write-Error { } - Mock Write-Debug { } + . $PSScriptRoot\Find-Chocolatey.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + + Mock Write-StatusMessage { } } Describe "Write-ChocolateyCache" { - Context "When Chocolatey is not installed" { - It "Should return false and write error" { + Context "When Get-ChocolateyCacheFile throws an exception" { + It "Should handle exception and return false" { + Mock Get-ChocolateyCacheFile { throw "Cache file path error" } + + $result = Write-ChocolateyCache + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error determining Chocolatey cache file path" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Chocolatey is not installed" { + It "Should return false and write error message" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } Mock Test-ChocolateyInstalled { return $false } - Mock Get-ChocolateyCacheFile { return "C:\fakepath\choco.cache" } + $result = Write-ChocolateyCache + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey is not installed. Cannot write cache file." -and $Verbosity -eq "Error" + } } } - Context "When cache file is written successfully" { - It "Should return true and write debug" { + Context "When Test-ChocolateyInstalled throws an exception" { + It "Should handle exception and return false" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } + Mock Test-ChocolateyInstalled { throw "Installation check failed" } + + $result = Write-ChocolateyCache + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error checking if Chocolatey is installed" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey throws an exception" { + It "Should handle exception and return false" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } Mock Test-ChocolateyInstalled { return $true } - Mock Get-ChocolateyCacheFile { return "C:\fakepath\choco.cache" } - Mock Invoke-Expression { "git|2.42.0`nnodejs|20.10.0" } - $script:setContentCalled = $false - Mock Set-Content -MockWith { - param($Path, $Value, $Force) - $script:setContentCalled = $true + Mock Find-Chocolatey { throw "Cannot locate chocolatey" } + + $result = Write-ChocolateyCache + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Error locating Chocolatey command" -and $Verbosity -eq "Error" } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When Find-Chocolatey returns null or empty" { + It "Should return false when Find-Chocolatey returns null (via exception path)" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } -Verifiable + Mock Test-ChocolateyInstalled { return $true } -Verifiable + Mock Find-Chocolatey { return $null } -Verifiable + Mock Invoke-Command { } # Should not be called normally + Mock Set-Content { } # Should not be called + $result = Write-ChocolateyCache - $result | Should -Be $true - $script:setContentCalled | Should -Be $true + + # Main assertion - function should return false + $result | Should -Be $false + } + + It "Should return false when Find-Chocolatey returns empty string (via exception path)" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } -Verifiable + Mock Test-ChocolateyInstalled { return $true } -Verifiable + Mock Find-Chocolatey { return "" } -Verifiable + Mock Invoke-Command { } # Should not be called normally + Mock Set-Content { } # Should not be called + + $result = Write-ChocolateyCache + + # Main assertion - function should return false + $result | Should -Be $false + } + + It "Should return false when Find-Chocolatey returns whitespace (via validation path)" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } -Verifiable + Mock Test-ChocolateyInstalled { return $true } -Verifiable + Mock Find-Chocolatey { return " " } -Verifiable + Mock Invoke-Command { } # Should not be called + Mock Set-Content { } # Should not be called + + $result = Write-ChocolateyCache + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Could not find Chocolatey command. Cannot write cache file." -and $Verbosity -eq "Warning" + } + Assert-MockCalled Invoke-Command -Exactly 0 -Scope It + Assert-MockCalled Set-Content -Exactly 0 -Scope It } } - Context "When writing cache file fails" { - It "Should return false and write error" { + Context "When Invoke-Command execution fails" { + It "Should handle Invoke-Command exception and return false" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } Mock Test-ChocolateyInstalled { return $true } - Mock Get-ChocolateyCacheFile { return "C:\fakepath\choco.cache" } - Mock Invoke-Expression { "git|2.42.0`nnodejs|20.10.0" } - Mock Set-Content { throw "Failed to write file" } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { throw "Command execution failed" } + $result = Write-ChocolateyCache + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to write Chocolatey cache file" -and $Verbosity -eq "Error" + } + } + + It "Should return false when LASTEXITCODE is not 0" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 1 + return @("git|2.42.0") + } + + $result = Write-ChocolateyCache + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Retrieved Chocolatey packages successfully." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to retrieve Chocolatey packages or no packages found." -and $Verbosity -eq "Warning" + } + } + + It "Should return false when no packages are returned" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + + $result = Write-ChocolateyCache + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Retrieved Chocolatey packages successfully." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to retrieve Chocolatey packages or no packages found." -and $Verbosity -eq "Warning" + } } } - Context "When choco command throws an exception" { - It "Should return false and write error" { + Context "When Set-Content fails" { + It "Should handle Set-Content exception and return false" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } Mock Test-ChocolateyInstalled { return $true } - Mock Get-ChocolateyCacheFile { return "C:\fakepath\choco.cache" } - Mock Invoke-Expression { throw "choco failed" } - $result = Write-ChocolateyCache + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0", "nodejs|20.10.0") + } + Mock Set-Content { throw "Access denied to cache file" } + + $result = Write-ChocolateyCache -Confirm:$false + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Retrieved Chocolatey packages successfully." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Failed to write Chocolatey cache file" -and $Verbosity -eq "Error" + } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When SupportsShouldProcess is tested" { + It "Should support -WhatIf parameter" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0", "nodejs|20.10.0") + } + Mock Set-Content { } + + $result = Write-ChocolateyCache -WhatIf + + $result | Should -Be $true + Assert-MockCalled Set-Content -Times 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Operation to write Chocolatey cache was cancelled." -and $Verbosity -eq "Warning" + } + } + + It "Should support -Confirm parameter" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } + Mock Test-ChocolateyInstalled { return $true } + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0", "nodejs|20.10.0") + } + Mock Set-Content { } + + $result = Write-ChocolateyCache -Confirm:$false + + $result | Should -Be $true + Assert-MockCalled Set-Content -Times 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey cache written successfully to:" -and $Verbosity -eq "Debug" + } + } + } + + Context "When cache is written successfully" { + It "Should return true and write debug messages when ShouldProcess is confirmed" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } -Verifiable + Mock Test-ChocolateyInstalled { return $true } -Verifiable + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } -Verifiable + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0", "nodejs|20.10.0") + } -Verifiable + Mock Set-Content { } -Verifiable + + $result = Write-ChocolateyCache -Confirm:$false + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Retrieved Chocolatey packages successfully." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Chocolatey cache written successfully to:" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Set-Content -Times 1 -Scope It -ParameterFilter { + $Path -eq "TestDrive:\choco.cache" -and $Force -eq $true + } + } + + It "Should return true and show cancellation message when ShouldProcess is declined" { + Mock Get-ChocolateyCacheFile { return "TestDrive:\choco.cache" } -Verifiable + Mock Test-ChocolateyInstalled { return $true } -Verifiable + Mock Find-Chocolatey { return "C:\ProgramData\chocolatey\bin\choco.exe" } -Verifiable + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("git|2.42.0", "nodejs|20.10.0") + } -Verifiable + Mock Set-Content { } -Verifiable + + $result = Write-ChocolateyCache -WhatIf + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Retrieved Chocolatey packages successfully." -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Operation to write Chocolatey cache was cancelled." -and $Verbosity -eq "Warning" + } + Assert-MockCalled Set-Content -Times 0 -Scope It } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.ps1 b/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.ps1 index 2058c2b..9d40bb1 100644 --- a/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.ps1 +++ b/DevSetup/Private/Providers/Chocolatey/Write-ChocolateyCache.ps1 @@ -56,33 +56,70 @@ #> Function Write-ChocolateyCache { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] + [OutputType([bool])] Param() - $cacheFile = Get-ChocolateyCacheFile + try { + $cacheFile = Get-ChocolateyCacheFile + } catch { + Write-StatusMessage "Error determining Chocolatey cache file path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + if(-not (Test-ChocolateyInstalled)) { + Write-StatusMessage "Chocolatey is not installed. Cannot write cache file." -Verbosity Error + return $false + } + } catch { + Write-StatusMessage "Error checking if Chocolatey is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + $chocoCommand = Find-Chocolatey + } catch { + Write-StatusMessage "Error locating Chocolatey command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - if(-not (Test-ChocolateyInstalled)) { - Write-Error "Chocolatey is not installed. Cannot write cache file." + if(-not $chocoCommand -or [string]::IsNullOrWhiteSpace($chocoCommand)) { + Write-StatusMessage "Could not find Chocolatey command. Cannot write cache file." -Verbosity Warning return $false } try { - #$chocolatelyPackages = @{} - #choco list -r | foreach-object { - # $package = $_ -split '\|' - # if($package.Count -eq 2) { - # $chocolatelyPackages[$package[0]] = @{ - # Name = $package[0] - # Version = $package[1] - # } - # } - #} - Invoke-Expression "& choco list -r" | Set-Content $cacheFile -Force - Write-Debug "Chocolatey cache written successfully to: $cacheFile" - return $true + $chocoPackages = Invoke-Command -ScriptBlock { & $chocoCommand list -r } 2>$null 3>$null 4>$null 5>$null 6>$null } catch { - Write-Error "Failed to write Chocolatey cache file: $_" + Write-StatusMessage "Failed to write Chocolatey cache file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "Retrieved Chocolatey packages successfully." -Verbosity Debug + if ($LASTEXITCODE -ne 0 -or -not $chocoPackages) { + Write-StatusMessage "Failed to retrieve Chocolatey packages or no packages found." -Verbosity Warning + return $false + } + + try { + if ($PSCmdlet.ShouldProcess($cacheFile, "Update Chocolatey cache")) { + $chocoPackages | Set-Content $cacheFile -Force + Write-StatusMessage "Chocolatey cache written successfully to: $cacheFile" -Verbosity Debug + return $true + } else { + Write-StatusMessage "Operation to write Chocolatey cache was cancelled." -Verbosity Warning + return $true + } + + } catch { + Write-StatusMessage "Failed to write Chocolatey cache file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Core/Install-CoreDependencies.Tests.ps1 b/DevSetup/Private/Providers/Core/Install-CoreDependencies.Tests.ps1 index 51bb937..4125bd5 100644 --- a/DevSetup/Private/Providers/Core/Install-CoreDependencies.Tests.ps1 +++ b/DevSetup/Private/Providers/Core/Install-CoreDependencies.Tests.ps1 @@ -2,6 +2,7 @@ BeforeAll { . (Join-Path $PSScriptRoot "Install-CoreDependencies.ps1") . (Join-Path $PSScriptRoot "Install-Nuget.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Get-DevSetupManifest.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Providers\Powershell\Install-PowershellModule.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Providers\Chocolatey\Install-Chocolatey.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Providers\Chocolatey\Install-ChocolateyPackage.ps1") @@ -125,13 +126,198 @@ Describe "Install-CoreDependencies" { } Context "When all core dependencies install successfully on non-Windows" { + BeforeEach { + Mock Write-StatusMessage { Write-Error $Message } + Mock Get-DevSetupManifest { return @{ RequiredModules = @("posh-git") } } + Mock Install-NuGet { return $true } + Mock Install-PowershellModule { return $true } + Mock Test-OperatingSystem { return $false } + Mock Install-Homebrew { return $true } + } It "Should skip Windows-only installs and return true" { + $result = Install-CoreDependencies + $result | Should -Be $true + Assert-MockCalled Install-Homebrew -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Homebrew installation succeeded" -and $Verbosity -eq "Debug" } + } + } + + Context "When install-homebrew fails on non-Windows" { + BeforeEach { + Mock Get-DevSetupManifest { return @{ RequiredModules = @("posh-git") } } + Mock Install-NuGet { return $true } + Mock Install-PowershellModule { return $true } + Mock Test-OperatingSystem { return $false } + Mock Install-Homebrew { return $false } + } + It "Should skip Windows-only installs and return false" { + $result = Install-CoreDependencies + $result | Should -Be $false + Assert-MockCalled Install-Homebrew -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to install Homebrew" -and $Verbosity -eq "Error" } + } + } + + Context "When PATH needs to be refreshed on Windows" { + BeforeEach { Mock Install-NuGet { return $true } Mock Get-DevSetupManifest { return @{ RequiredModules = @("posh-git") } } Mock Install-PowershellModule { return $true } - Mock Test-OperatingSystem { param($os) return $false } + Mock Install-Chocolatey { return $true } + Mock Install-ChocolateyPackage { return $true } + Mock Install-Scoop { return $true } + Mock Test-OperatingSystem { param($Windows) if ($Windows) { return $true } else { return $false } } + + # Store original PATH to restore later + $script:originalPath = $env:PATH + + # Store original environment variable values + $script:originalUserPath = [System.Environment]::GetEnvironmentVariable("PATH", "User") + $script:originalMachinePath = [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + } + + AfterEach { + # Restore original PATH and environment variables + if ($script:originalPath) { + $env:PATH = $script:originalPath + } + } + + It "Should execute PATH refresh logic when User/Machine paths have new entries" { + # Create test directories using TestDrive for cross-platform compatibility + $testUserPath = Join-Path $TestDrive "user\bin" + $testMachinePath = Join-Path $TestDrive "machine\bin" + $testSystemPath = Join-Path $TestDrive "system" + New-Item -Path $testUserPath -ItemType Directory -Force + New-Item -Path $testMachinePath -ItemType Directory -Force + New-Item -Path $testSystemPath -ItemType Directory -Force + + # Mock Get-EnvironmentVariable to return test paths based on scope + Mock Get-EnvironmentVariable { + param($Name, $Scope) + if ($Name -eq "PATH") { + switch ($Scope) { + "User" { return $testUserPath } + "Machine" { return $testMachinePath } + default { return $null } + } + } + return $null + } + + # Set up a scenario where current PATH is minimal (using TestDrive path) + $originalPath = $env:PATH + $env:PATH = $testSystemPath + + try { + $result = Install-CoreDependencies + $result | Should -Be $true + + # The PATH should be longer than the original minimal path + # This indirectly tests that the PATH refresh logic was executed + $env:PATH.Length | Should -BeGreaterThan $testSystemPath.Length + + # The PATH should now include the test user and machine paths + $env:PATH | Should -Match ([regex]::Escape($testUserPath)) + $env:PATH | Should -Match ([regex]::Escape($testMachinePath)) + } + finally { + # Restore original PATH + $env:PATH = $originalPath + } + } + + It "Should handle scenario where all paths already exist in current PATH" { + # Set up current PATH that already contains all User and Machine paths + $currentUserPath = [System.Environment]::GetEnvironmentVariable("PATH", "User") + $currentMachinePath = [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + + # Build a comprehensive PATH that includes everything + $allPaths = @() + if ($currentUserPath) { $allPaths += $currentUserPath.Split(';') | Where-Object { $_ } } + if ($currentMachinePath) { $allPaths += $currentMachinePath.Split(';') | Where-Object { $_ } } + $env:PATH = ($allPaths | Select-Object -Unique) -join ';' + + $pathBefore = $env:PATH $result = Install-CoreDependencies $result | Should -Be $true + + # PATH should remain essentially the same (no duplicates added) + $env:PATH.Length | Should -BeGreaterOrEqual $pathBefore.Length + # No significant increase in length (allowing for minor formatting differences) + ($env:PATH.Length - $pathBefore.Length) | Should -BeLessThan 100 } - } + + It "Should add paths from User and Machine PATH that are not in current session PATH" { + # This test specifically targets the missed coverage lines (134, 141, 148) + + # Create a minimal current PATH that doesn't include common User/Machine paths + $env:PATH = "C:\Windows\System32" + + # Get the actual current User and Machine paths from registry + $userPath = [System.Environment]::GetEnvironmentVariable("PATH", "User") + $machinePath = [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + + $pathBefore = $env:PATH + $result = Install-CoreDependencies + $result | Should -Be $true + + # The function should have added User and Machine paths to the current PATH + # This will hit lines 134, 141, and 148 if User/Machine paths exist and are not in current PATH + if ($userPath -or $machinePath) { + # PATH should be significantly longer than the minimal starting PATH + $env:PATH | Should -Not -Be $pathBefore + $env:PATH.Length | Should -BeGreaterThan $pathBefore.Length + + # If User path exists, check that unique User paths were added + if ($userPath) { + $userPaths = $userPath.Split(';') | Where-Object { $_ -and $pathBefore -notlike "*$_*" } + foreach ($path in $userPaths) { + if ($path) { + $env:PATH | Should -BeLike "*$path*" + } + } + } + + # If Machine path exists, check that unique Machine paths were added + if ($machinePath) { + $machinePaths = $machinePath.Split(';') | Where-Object { $_ -and $pathBefore -notlike "*$_*" } + foreach ($path in $machinePaths) { + if ($path) { + $env:PATH | Should -BeLike "*$path*" + } + } + } + } + } + + It "Should handle empty User and Machine PATH variables" { + # Mock empty PATH variables to test the null handling + $originalGetEnv = ${function:global:GetEnvironmentVariable} + + # Create a mock that returns empty for PATH variables + ${function:global:GetEnvironmentVariable} = { + param($Name, $Target) + if ($Name -eq "PATH") { + return $null + } + return $originalGetEnv.Invoke($Name, $Target) + } + + try { + $pathBefore = $env:PATH + $result = Install-CoreDependencies + $result | Should -Be $true + + # PATH should remain unchanged if no User/Machine paths exist + $env:PATH | Should -Be $pathBefore + } + finally { + # Restore original function + if ($originalGetEnv) { + ${function:global:GetEnvironmentVariable} = $originalGetEnv + } + } + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Core/Install-CoreDependencies.ps1 b/DevSetup/Private/Providers/Core/Install-CoreDependencies.ps1 index 5ef3ceb..6eff0fb 100644 --- a/DevSetup/Private/Providers/Core/Install-CoreDependencies.ps1 +++ b/DevSetup/Private/Providers/Core/Install-CoreDependencies.ps1 @@ -77,6 +77,8 @@ Function Install-CoreDependencies { Write-StatusMessage "Failed to install NuGet PackageProvider" -Verbosity Error return $false } + } else { + Write-StatusMessage "Skipping NuGet installation on non-Windows platform" -Verbosity Debug } # Get required modules from DevSetup manifest @@ -119,7 +121,32 @@ Function Install-CoreDependencies { Write-StatusMessage "[OK]" -ForegroundColor Green } - $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH", "User") + ";" + [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + # Refresh PATH to include newly installed Git, but preserve existing session paths + $userPath = Get-EnvironmentVariable -Name "PATH" -Scope "User" + $machinePath = Get-EnvironmentVariable -Name "PATH" -Scope "Machine" + $currentPath = $env:PATH + + # Only add paths that aren't already in the current PATH + $pathsToAdd = @() + if ($userPath) { + $userPath.Split(';') | ForEach-Object { + if ($_ -and $currentPath -notlike "*$_*") { + $pathsToAdd += $_ + } + } + } + if ($machinePath) { + $machinePath.Split(';') | ForEach-Object { + if ($_ -and $currentPath -notlike "*$_*") { + $pathsToAdd += $_ + } + } + } + + # Append new paths to existing PATH instead of replacing it + if ($pathsToAdd.Count -gt 0) { + $env:PATH = $currentPath + ";" + ($pathsToAdd -join ";") + } # Install Scoop PackageProvider if (-not (Install-Scoop)) { @@ -127,9 +154,12 @@ Function Install-CoreDependencies { return $false } } else { + Write-StatusMessage "Skipping Windows-only installations on non-Windows platform" -Verbosity Debug if (-not (Install-Homebrew)) { Write-StatusMessage "Failed to install Homebrew" -Verbosity Error return $false + } else { + Write-StatusMessage "Homebrew installation succeeded" -Verbosity Debug } } diff --git a/DevSetup/Private/Providers/Core/Install-GitRepository.Tests.ps1 b/DevSetup/Private/Providers/Core/Install-GitRepository.Tests.ps1 new file mode 100644 index 0000000..580bb2e --- /dev/null +++ b/DevSetup/Private/Providers/Core/Install-GitRepository.Tests.ps1 @@ -0,0 +1,195 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Install-GitRepository.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") +} + +Describe "Install-GitRepository" { + + BeforeEach { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } # Default to Windows + Mock Get-Command { [PSCustomObject]@{ Name = "git"; Path = "git" } } # Default to found in PATH + Mock Test-Path { $false } # Default to not exist + Mock Invoke-Command { } + Mock Remove-Item { } + Mock Push-Location { } + Mock Pop-Location { } + Mock Write-Host { } + Mock Write-Error { } + Mock Write-StatusMessage { } + $global:LASTEXITCODE = 0 # Default to success + } + + Context "When Git is not in PATH and not at common path" { + It "Should return false and write error" { + Mock Get-Command { $null } + Mock Test-Path { $false } + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Git is not installed or not found in PATH. Please install Git and try again." -and $Verbosity -eq "Error" } + } + } + + Context "When Git is in PATH" { + It "Should use git from PATH and clone successfully" { + Mock Test-Path { $false } + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $true + Assert-MockCalled Get-Command -Exactly 1 -Scope It -ParameterFilter { $Name -eq "git" } + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Git found in PATH" -and $ForegroundColor -eq "Gray" } + } + } + + Context "When Git is not in PATH but at common path" { + It "Should use git from common path and clone successfully" { + Mock Get-Command { $null } + Mock Test-Path { Param($Path) { if ($Path -eq "C:\Program Files\Git\cmd\git.exe") { return $true } else { return $false } } } + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $true + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq "C:\Program Files\Git\cmd\git.exe" } + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Using Git from: C:\Program Files\Git\cmd\git.exe" -and $ForegroundColor -eq "Gray" } + } + } + + Context "When destination exists and UpdateExisting is specified" { + It "Should pull updates and return true" { + Mock Test-Path { Param($Path) { if ($Path -eq "$TestDrive\repo") { return $true } else { return $false } } } + $global:LASTEXITCODE = 0 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" -UpdateExisting + $result | Should -Be $true + Assert-MockCalled Push-Location -Exactly 1 -Scope It -ParameterFilter { $Path -eq "$TestDrive\repo" } + Assert-MockCalled Pop-Location -Exactly 1 -Scope It + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + } + } + + Context "When destination exists and UpdateExisting is not specified" { + It "Should remove existing and clone" { + Mock Test-Path { Param($Path) { if ($Path -eq "$TestDrive\repo") { return $true } else { return $false } } } + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $true + Assert-MockCalled Remove-Item -Exactly 1 -Scope It -ParameterFilter { $Path -eq "$TestDrive\repo" } + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Removing existing directory to perform fresh clone: $TestDrive\repo" -and $ForegroundColor -eq "Yellow" } + } + } + + Context "When clone succeeds without branch" { + It "Should clone and return true" { + Mock Test-Path { $false } + $global:LASTEXITCODE = 0 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $true + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + } + } + + Context "When clone succeeds with branch" { + It "Should clone specific branch and return true" { + Mock Test-Path { $false } + $global:LASTEXITCODE = 0 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" -Branch "develop" + $result | Should -Be $true + Assert-MockCalled Invoke-Command -Exactly 1 -Scope It + } + } + + Context "When clone fails" { + It "Should return false and write error" { + Mock Test-Path { $false } + $global:LASTEXITCODE = 1 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Failed to clone repository from https://github.com/user/repo.git to $TestDrive\repo" -and $Verbosity -eq "Error"} + } + } + + Context "When pull fails" { + It "Should return false and write error" { + Mock Test-Path { Param($Path) { if ($Path -eq "$TestDrive\repo") { return $true } else { return $false } } } + $global:LASTEXITCODE = 1 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" -UpdateExisting + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Failed to update repository at $TestDrive\repo" -and $Verbosity -eq "Error"} + } + } + + Context "When exception occurs" { + It "Should return false and write error" { + Mock Invoke-Command { throw "Command failed" } + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error cloning repository:" -and $Verbosity -eq "Error"} + } + } + + Context "Coverage-focused tests for success paths" { + It "Should hit pull success path without Write-StatusMessage mock interference" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + Mock Get-Command { [PSCustomObject]@{ Name = "git"; Path = "git" } } + Mock Test-Path { Param($Path) { if ($Path -eq "$TestDrive\repo") { return $true } else { return $false } } } + Mock Invoke-Command { } + Mock Push-Location { } + Mock Pop-Location { } + $global:LASTEXITCODE = 0 + + # Don't mock Write-StatusMessage for this test to ensure success path is hit + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" -UpdateExisting + $result | Should -Be $true + } + + It "Should hit clone success path without Write-StatusMessage mock interference" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + Mock Get-Command { [PSCustomObject]@{ Name = "git"; Path = "git" } } + Mock Test-Path { $false } + Mock Invoke-Command { } + $global:LASTEXITCODE = 0 + + # Don't mock Write-StatusMessage for this test to ensure success path is hit + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $true + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Test-Path { $false } + $global:LASTEXITCODE = 0 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive\repo" + $result | Should -Be $true + } + + It "Should work on Linux" { + Mock Get-Command { [PSCustomObject]@{ Name = "git"; Path = "/usr/bin/git" } } + Mock Test-Path { $false } + $global:LASTEXITCODE = 0 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive/repo" + $result | Should -Be $true + } + + It "Should work on macOS" { + Mock Get-Command { [PSCustomObject]@{ Name = "git"; Path = "/usr/local/bin/git" } } + Mock Test-Path { $false } + $global:LASTEXITCODE = 0 + $result = Install-GitRepository -RepositoryUrl "https://github.com/user/repo.git" -DestinationPath "$TestDrive/Srepo" + $result | Should -Be $true + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Core/Install-GitRepository.ps1 b/DevSetup/Private/Providers/Core/Install-GitRepository.ps1 index 19e68e1..67f4c44 100644 --- a/DevSetup/Private/Providers/Core/Install-GitRepository.ps1 +++ b/DevSetup/Private/Providers/Core/Install-GitRepository.ps1 @@ -101,40 +101,40 @@ Function Install-GitRepository { # Check common Git installation path $gitPath = "C:\Program Files\Git\cmd\git.exe" if (Test-Path $gitPath) { - Write-Host "Using Git from: $gitPath" -ForegroundColor Gray + Write-StatusMessage "Using Git from: $gitPath" -ForegroundColor Gray # Use the full path for git commands $gitExecutable = $gitPath } else { - Write-Error "Git is not installed or not found in PATH. Please install Git and try again." + Write-StatusMessage "Git is not installed or not found in PATH. Please install Git and try again." -Verbosity Error return $false } } else { $gitExecutable = "git" - Write-Host "Git found in PATH" -ForegroundColor Gray + Write-StatusMessage "Git found in PATH" -ForegroundColor Gray } try { # Check if destination already exists if (Test-Path -Path $DestinationPath) { if ($UpdateExisting) { - Write-Host "Updating existing repository at $DestinationPath" -ForegroundColor Yellow + Write-StatusMessage "Updating existing repository at $DestinationPath" -ForegroundColor Yellow # Change to the repository directory and pull updates Push-Location $DestinationPath try { - & $gitExecutable pull + Invoke-Command -ScriptBlock { & $gitExecutable pull } if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to update repository at $DestinationPath" + Write-StatusMessage "Failed to update repository at $DestinationPath" -Verbosity Error return $false } - Write-Host "Repository updated successfully" -ForegroundColor Green + Write-StatusMessage "Repository updated successfully" -ForegroundColor Green return $true } finally { Pop-Location } } else { - Write-Host "Removing existing directory to perform fresh clone: $DestinationPath" -ForegroundColor Yellow + Write-StatusMessage "Removing existing directory to perform fresh clone: $DestinationPath" -ForegroundColor Yellow Remove-Item -Path $DestinationPath -Recurse -Force } } @@ -146,9 +146,9 @@ Function Install-GitRepository { if (-not [string]::IsNullOrWhiteSpace($Branch)) { $gitArgs += "--branch" $gitArgs += $Branch - Write-Host "Cloning repository from $RepositoryUrl (branch: $Branch) to $DestinationPath" -ForegroundColor Cyan + Write-StatusMessage "Cloning repository from $RepositoryUrl (branch: $Branch) to $DestinationPath" -ForegroundColor Cyan } else { - Write-Host "Cloning repository from $RepositoryUrl (default branch) to $DestinationPath" -ForegroundColor Cyan + Write-StatusMessage "Cloning repository from $RepositoryUrl (default branch) to $DestinationPath" -ForegroundColor Cyan } # Add repository URL and destination path @@ -156,18 +156,17 @@ Function Install-GitRepository { $gitArgs += $DestinationPath # Execute git clone command - & $gitExecutable @gitArgs - + Invoke-Command -ScriptBlock { & $gitExecutable @gitArgs } if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to clone repository from $RepositoryUrl to $DestinationPath" + Write-StatusMessage "Failed to clone repository from $RepositoryUrl to $DestinationPath" -Verbosity Error return $false } - Write-Host "Repository cloned successfully to $DestinationPath" -ForegroundColor Green + Write-StatusMessage "Repository cloned successfully to $DestinationPath" -ForegroundColor Green return $true } catch { - Write-Error "Error cloning repository: $_" + Write-StatusMessage "Error cloning repository: $_" -Verbosity Error return $false } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.Tests.ps1 b/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.Tests.ps1 index 2b0222c..a470efc 100644 --- a/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.Tests.ps1 +++ b/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.Tests.ps1 @@ -1,7 +1,10 @@ BeforeAll { - Function ConvertTo-Yaml { } + # Define Write-EZLog function to avoid dependency issues + Function Write-EZLog { } + . (Join-Path $PSScriptRoot "Invoke-HomebrewComponentsExport.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Read-ConfigurationFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Update-DevSetupEnvFile.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Providers\Homebrew\Find-Homebrew.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Invoke-ExternalCommand.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") @@ -10,7 +13,7 @@ BeforeAll { Describe "Invoke-HomebrewComponentsExport" { Context "When Homebrew is not installed" { It "should return false" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ } } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ } } } } Mock Find-Homebrew { $null } Mock Write-StatusMessage { } @@ -23,104 +26,229 @@ Describe "Invoke-HomebrewComponentsExport" { Context "When export succeeds" { It "should update YAML data and save the file" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } Mock Find-Homebrew { "/usr/local/bin/brew" } Mock Invoke-ExternalCommand { - Param($Command, $Arguments) - if ($Arguments -contains "list --versions") { - return "git 2.30.1`nnode 14.17.0" - } elseif ($Arguments -contains "list --installed-on-request") { - return "git`nnode" + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git 2.30.1", "node 14.17.0") + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git", "node") } + return @() } - Mock ConvertTo-Yaml { "mock yaml output" } - Mock Out-File { } + Mock Update-DevSetupEnvFile { } Mock Write-StatusMessage { } $result = Invoke-HomebrewComponentsExport -Config "test.yaml" $result | Should -Be $true - Assert-MockCalled Read-ConfigurationFile -Exactly 1 -Scope It + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It Assert-MockCalled Find-Homebrew -Exactly 3 -Scope It Assert-MockCalled Invoke-ExternalCommand -Exactly 2 -Scope It - Assert-MockCalled ConvertTo-Yaml -Exactly 1 -Scope It - Assert-MockCalled Out-File -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Configuration saved successfully" } } - } - Context "When YAML conversion fails" { - It "should fall back to JSON and save" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + It "should update existing packages instead of adding duplicates" { + # Mock existing homebrew packages in the config + Mock Read-DevSetupEnvFile { + @{ + devsetup = @{ + dependencies = @{ + homebrew = @( + @{ name = "git"; minimumVersion = "2.25.0" }, + @{ name = "node"; minimumVersion = "14.0.0" } + ) + } + } + } + } Mock Find-Homebrew { "/usr/local/bin/brew" } Mock Invoke-ExternalCommand { - Param($Command, $Arguments) - if ($Arguments -contains "list --versions") { - return "git 2.30.1" - } elseif ($Arguments -contains "list --installed-on-request") { - return "git" + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git 2.30.1", "node 14.17.0") + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git", "node") + } + return @() + } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + + $result = Invoke-HomebrewComponentsExport -Config "test.yaml" + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Updating package: git" } + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Updating package: node" } + } + + It "should handle mixed scenario of existing and new packages" { + # Mock existing homebrew packages in the config, but installed packages include new ones + Mock Read-DevSetupEnvFile { + @{ + devsetup = @{ + dependencies = @{ + homebrew = @( + @{ name = "git"; minimumVersion = "2.25.0" } + ) + } + } + } + } + Mock Find-Homebrew { "/usr/local/bin/brew" } + Mock Invoke-ExternalCommand { + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git 2.30.1", "node 14.17.0", "wget 1.21.0") + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git", "node", "wget") } + return @() } - Mock ConvertTo-Yaml { throw "YAML conversion failed" } - Mock ConvertTo-Json { "mock json output" } - Mock Out-File { } + Mock Update-DevSetupEnvFile { } Mock Write-StatusMessage { } $result = Invoke-HomebrewComponentsExport -Config "test.yaml" $result | Should -Be $true - Assert-MockCalled ConvertTo-Yaml -Exactly 1 -Scope It - Assert-MockCalled ConvertTo-Json -Exactly 1 -Scope It - Assert-MockCalled Out-File -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Updating package: git" } + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Adding package: node" } + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Adding package: wget" } + } + + It "should handle packages with different version formats" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Find-Homebrew { "/usr/local/bin/brew" } + Mock Invoke-ExternalCommand { + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git 2.30.1_1", "node 14.17.0", "python@3.9 3.9.5") # Different version formats + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git", "node", "python@3.9") + } + return @() + } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + + $result = Invoke-HomebrewComponentsExport -Config "test.yaml" + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Adding package: git" } + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Adding package: node" } + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Adding package: python@3.9" } + } + + It "should handle packages with no version information" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Find-Homebrew { "/usr/local/bin/brew" } + Mock Invoke-ExternalCommand { + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git", "node 14.17.0") # git has no version, node has version + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git", "node") + } + return @() + } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + + $result = Invoke-HomebrewComponentsExport -Config "test.yaml" + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Adding package: git" } + Assert-MockCalled Write-StatusMessage -Times 1 -Scope It -ParameterFilter { $Message -match "Adding package: node" } } } Context "When saving fails" { It "should return false" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } Mock Find-Homebrew { "/usr/local/bin/brew" } Mock Invoke-ExternalCommand { - Param($Command, $Arguments) - if ($Arguments -contains "list --versions") { - return "git 2.30.1" - } elseif ($Arguments -contains "list --installed-on-request") { - return "git" + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git 2.30.1") + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git") } + return @() } - Mock ConvertTo-Yaml { "mock yaml output" } - Mock Out-File { throw "Save failed" } + Mock Update-DevSetupEnvFile { throw "Save failed" } Mock Write-StatusMessage { } $result = Invoke-HomebrewComponentsExport -Config "test.yaml" $result | Should -Be $false - Assert-MockCalled Out-File -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to save configuration" } } } Context "When WhatIf is specified" { It "should not save the file" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } Mock Find-Homebrew { "/usr/local/bin/brew" } Mock Invoke-ExternalCommand { - Param($Command, $Arguments) - if ($Arguments -contains "list --versions") { - return "git 2.30.1" - } elseif ($Arguments -contains "list --installed-on-request") { - return "git" + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git 2.30.1") + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git") } + return @() } - Mock ConvertTo-Yaml { "mock yaml output" } - Mock Out-File { } + Mock Update-DevSetupEnvFile { } Mock Write-StatusMessage { } ($result = Invoke-HomebrewComponentsExport -Config "test.yaml" -WhatIf:$true) *> $null $result | Should -Be $true - Assert-MockCalled Out-File -Exactly 0 -Scope It # Should not save + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It # Should not save + } + } + + Context "Edge cases and error handling" { + It "should handle empty package list" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Find-Homebrew { "/usr/local/bin/brew" } + Mock Invoke-ExternalCommand { + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return "" # Empty package list + } elseif ($Arguments[1] -match "list --installed-on-request") { + return "" # Empty package list + } + return @() + } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + + $result = Invoke-HomebrewComponentsExport -Config "test.yaml" + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + + It "should handle custom output file parameter" { + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Find-Homebrew { "/usr/local/bin/brew" } + Mock Invoke-ExternalCommand { + Param($Arguments) + if ($Arguments[1] -match "list --versions") { + return @("git 2.30.1") + } elseif ($Arguments[1] -match "list --installed-on-request") { + return @("git") + } + return @() + } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + + $result = Invoke-HomebrewComponentsExport -Config "test.yaml" -OutFile "custom.yaml" + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It -ParameterFilter { $EnvFilePath -eq "custom.yaml" } } } Context "Cross-platform compatibility" { It "should handle Windows (where Homebrew is unlikely)" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ } } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ } } } } Mock Find-Homebrew { $null } Mock Write-StatusMessage { } @@ -129,18 +257,17 @@ Describe "Invoke-HomebrewComponentsExport" { } It "should work on Linux" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } Mock Find-Homebrew { "/home/linuxbrew/.linuxbrew/bin/brew" } Mock Invoke-ExternalCommand { - Param($Command, $Arguments) + Param($Arguments) if ($Arguments -contains "list --versions") { return "git 2.30.1" } elseif ($Arguments -contains "list --installed-on-request") { return "git" } } - Mock ConvertTo-Yaml { "mock yaml output" } - Mock Out-File { } + Mock Update-DevSetupEnvFile { } Mock Write-StatusMessage { } $result = Invoke-HomebrewComponentsExport -Config "test.yaml" @@ -148,18 +275,17 @@ Describe "Invoke-HomebrewComponentsExport" { } It "should work on macOS" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ homebrew = @() } } } } Mock Find-Homebrew { "/opt/homebrew/bin/brew" } Mock Invoke-ExternalCommand { - Param($Command, $Arguments) + Param($Arguments) if ($Arguments -contains "list --versions") { return "git 2.30.1" } elseif ($Arguments -contains "list --installed-on-request") { return "git" } } - Mock ConvertTo-Yaml { "mock yaml output" } - Mock Out-File { } + Mock Update-DevSetupEnvFile { } Mock Write-StatusMessage { } $result = Invoke-HomebrewComponentsExport -Config "test.yaml" diff --git a/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.ps1 b/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.ps1 index b3579c4..e8bbb74 100644 --- a/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.ps1 +++ b/DevSetup/Private/Providers/Homebrew/Invoke-HomebrewComponentsExport.ps1 @@ -8,11 +8,9 @@ Function Invoke-HomebrewComponentsExport { [string]$OutFile ) - $YamlData = Read-ConfigurationFile -Config $Config + $YamlData = Read-DevSetupEnvFile -Config $Config - # Ensure scoopPackages and scoopBuckets sections exist - if (-not $YamlData.devsetup) { $YamlData.devsetup = @{} } - if (-not $YamlData.devsetup.dependencies) { $YamlData.devsetup.dependencies = @{} } + # Ensure homebrew section exists (specific to this provider) if (-not $YamlData.devsetup.dependencies.homebrew) { $YamlData.devsetup.dependencies.homebrew = @() } if(-not (Find-Homebrew)) { @@ -51,22 +49,12 @@ Function Invoke-HomebrewComponentsExport { } } - try { - $yamlOutput = $YamlData | ConvertTo-Yaml - } - catch { - Write-StatusMessage "Could not convert to YAML format. Showing PowerShell object instead:" -Verbosity Warning - $yamlOutput = $YamlData | ConvertTo-Json -Depth 10 - } - # Determine output file $outputFile = if ($OutFile) { $OutFile } else { $Config } try { Write-StatusMessage "Saving configuration to: $outputFile" -Verbosity Verbose - if ($PSCmdlet.ShouldProcess($outputFile, "Out-File")) { - $yamlOutput | Out-File -FilePath $outputFile - } + $YamlData | Update-DevSetupEnvFile -EnvFilePath $outputFile -WhatIf:$WhatIf Write-StatusMessage "Configuration saved successfully!" -Verbosity Verbose } catch { diff --git a/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.Tests.ps1 b/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.Tests.ps1 index dd68137..008e908 100644 --- a/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.Tests.ps1 +++ b/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.Tests.ps1 @@ -10,7 +10,15 @@ Describe "Read-HomebrewCache" { Mock Get-HomebrewCacheFile { $mockCachePath } Mock Test-Path { $true } Mock Get-Content { '{"package1": "version1", "package2": "version2"}' } - Mock ConvertFrom-Json { @{ package1 = "version1"; package2 = "version2" } } + + # Mock ConvertFrom-Json to return PSCustomObject as it would in real usage + Mock ConvertFrom-Json { + $obj = [PSCustomObject]@{ + package1 = "version1" + package2 = "version2" + } + return $obj + } $result = Read-HomebrewCache $result | Should -BeOfType [hashtable] @@ -47,7 +55,10 @@ Describe "Read-HomebrewCache" { Mock Get-HomebrewCacheFile { $mockCachePath } Mock Test-Path { $true } Mock Get-Content { '{"git": "2.30.1"}' } - Mock ConvertFrom-Json { @{ git = "2.30.1" } } + Mock ConvertFrom-Json { + $obj = [PSCustomObject]@{ git = "2.30.1" } + return $obj + } $result = Read-HomebrewCache $result["git"] | Should -Be "2.30.1" @@ -58,7 +69,10 @@ Describe "Read-HomebrewCache" { Mock Get-HomebrewCacheFile { $mockCachePath } Mock Test-Path { $true } Mock Get-Content { '{"git": "2.30.1"}' } - Mock ConvertFrom-Json { @{ git = "2.30.1" } } + Mock ConvertFrom-Json { + $obj = [PSCustomObject]@{ git = "2.30.1" } + return $obj + } $result = Read-HomebrewCache $result["git"] | Should -Be "2.30.1" @@ -69,10 +83,38 @@ Describe "Read-HomebrewCache" { Mock Get-HomebrewCacheFile { $mockCachePath } Mock Test-Path { $true } Mock Get-Content { '{"git": "2.30.1"}' } - Mock ConvertFrom-Json { @{ git = "2.30.1" } } + Mock ConvertFrom-Json { + $obj = [PSCustomObject]@{ git = "2.30.1" } + return $obj + } $result = Read-HomebrewCache $result["git"] | Should -Be "2.30.1" } + + It "should convert PSCustomObject to Hashtable correctly" { + $mockCachePath = Join-Path $TestDrive "homebrew.cache" + Mock Get-HomebrewCacheFile { $mockCachePath } + Mock Test-Path { $true } + Mock Get-Content { '{"node": "16.0.0", "npm": "7.10.0", "git": "2.30.1"}' } + Mock ConvertFrom-Json { + $obj = [PSCustomObject]@{ + node = "16.0.0" + npm = "7.10.0" + git = "2.30.1" + } + return $obj + } + + $result = Read-HomebrewCache + $result | Should -BeOfType [hashtable] + $result.Count | Should -Be 3 + $result.ContainsKey("node") | Should -Be $true + $result.ContainsKey("npm") | Should -Be $true + $result.ContainsKey("git") | Should -Be $true + $result["node"] | Should -Be "16.0.0" + $result["npm"] | Should -Be "7.10.0" + $result["git"] | Should -Be "2.30.1" + } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.ps1 b/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.ps1 index 40d0b1a..3acddef 100644 --- a/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.ps1 +++ b/DevSetup/Private/Providers/Homebrew/Read-HomebrewCache.ps1 @@ -6,8 +6,15 @@ Function Read-HomebrewCache { $cacheFile = Get-HomebrewCacheFile if (Test-Path $cacheFile) { - $cacheData = Get-Content -Path $cacheFile | ConvertFrom-Json -AsHashtable - return $cacheData + $jsonData = Get-Content -Path $cacheFile | ConvertFrom-Json + + # Convert PSCustomObject to Hashtable for cross-platform compatibility + $hashtable = @{} + $jsonData.PSObject.Properties | ForEach-Object { + $hashtable[$_.Name] = $_.Value + } + + return $hashtable } return @{} diff --git a/DevSetup/Private/Providers/Powershell/Export-InstalledPowershellModules.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Export-InstalledPowershellModules.Tests.ps1 deleted file mode 100644 index 439119f..0000000 --- a/DevSetup/Private/Providers/Powershell/Export-InstalledPowershellModules.Tests.ps1 +++ /dev/null @@ -1,139 +0,0 @@ -BeforeAll { - function ConvertTo-Yaml { } - . $PSScriptRoot\Export-InstalledPowershellModules.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Read-ConfigurationFile.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-DevSetupManifest.ps1 - Mock Test-RunningAsAdmin { $true } - Mock Get-InstalledModule { @( - @{ Name = "ModuleA"; Version = [version]"1.0.0" }, - @{ Name = "ModuleB"; Version = [version]"2.0.0" } - ) } - Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } - Mock Get-Module { param($Name) @{ Name = $Name; ModuleBase = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\$Name"; Version = [version]"1.0.0" } } - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @() } } } } } - Mock ConvertTo-Yaml { param($obj) "yaml-output" } - Mock ConvertTo-Json { param($obj) "json-output" } - Mock Out-File { } - Mock Write-Host { } - Mock Write-Warning { } - Mock Write-Error { } - Mock Write-Debug { } - Mock Write-Verbose { } -} - -Describe "Export-InstalledPowershellModules" { - - Context "When not running as administrator" { - It "Should throw and return false" { - Mock Test-RunningAsAdmin { $false } - $result = Export-InstalledPowershellModules -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "requires administrator privileges" } - } - } - - Context "When no modules are found" { - It "Should warn and return true" { - Mock Get-InstalledModule { @() } - $result = Export-InstalledPowershellModules -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "No PowerShell modules found" } - } - } - - Context "When core dependency modules are present" { - It "Should skip core dependency modules" { - Mock Get-InstalledModule { @( - @{ Name = "ModuleA"; Version = [version]"1.0.0" }, - @{ Name = "ModuleB"; Version = [version]"2.0.0" } - ) } - Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } - $result = Export-InstalledPowershellModules -Config "test.yaml" - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Adding module: ModuleB" } - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -notmatch "Adding module: ModuleA" } - } - } - - Context "When modules are found and added to config" { - It "Should add new modules to YAML data" { - $result = Export-InstalledPowershellModules -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Adding module: ModuleB" } - } - } - - Context "When module version changes" { - It "Should update the module version in the config" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; minimumVersion = "1.0.0"; scope = "CurrentUser" }) } } } } } - Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0" }) } - $result = Export-InstalledPowershellModules -Config "test.yaml" - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Updating module: ModuleB" } - } - } - - Context "When module exists but has no version" { - It "Should add minimumVersion to the module" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; scope = "CurrentUser" }) } } } } } - Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0" }) } - $result = Export-InstalledPowershellModules -Config "test.yaml" - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Updating module version: ModuleB" } - } - } - - Context "When module is unchanged" { - It "Should skip updating the module" { - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; minimumVersion = "2.0.0"; scope = "CurrentUser" }) } } } } } - Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0" }) } - $result = Export-InstalledPowershellModules -Config "test.yaml" - #Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Skipping module (No Change): ModuleB" } - } - } - - Context "When DryRun is used" { - It "Should display YAML output and not write to file" { - $result = Export-InstalledPowershellModules -Config "test.yaml" -DryRun - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Yaml -Scope It - Assert-MockCalled Out-File -Times 0 -Scope It - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Dry Run" } - } - } - - Context "When OutFile is specified" { - It "Should write YAML output to the specified file" { - $result = Export-InstalledPowershellModules -Config "test.yaml" -OutFile "out.yaml" - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Yaml -Scope It - Assert-MockCalled Out-File -Scope It -ParameterFilter { $FilePath -eq "out.yaml" } - } - } - - Context "When YAML conversion fails" { - It "Should fallback to JSON output" { - Mock ConvertTo-Yaml { throw "YAML error" } - $result = Export-InstalledPowershellModules -Config "test.yaml" -DryRun - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Json -Scope It - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "Could not convert to YAML format" } - } - } - - Context "When Out-File fails" { - It "Should write error and return false" { - Mock Out-File { throw "File error" } - $result = Export-InstalledPowershellModules -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Failed to save configuration" } - } - } - - Context "When an unexpected error occurs" { - It "Should write error and return false" { - Mock Get-InstalledModule { throw "Unexpected error" } - $result = Export-InstalledPowershellModules -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Error converting PowerShell modules" } - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Export-InstalledPowershellModules.ps1 b/DevSetup/Private/Providers/Powershell/Export-InstalledPowershellModules.ps1 deleted file mode 100644 index bd1e7f7..0000000 --- a/DevSetup/Private/Providers/Powershell/Export-InstalledPowershellModules.ps1 +++ /dev/null @@ -1,264 +0,0 @@ -๏ปฟ<# -.SYNOPSIS - Exports installed PowerShell modules to a YAML configuration file. - -.DESCRIPTION - This function scans the system for installed PowerShell modules and exports them to a YAML - configuration file in DevSetup format. It uses Get-InstalledModule to retrieve comprehensive - module information including versions and installation scope. The function intelligently skips - core dependency modules defined in the DevSetup manifest and can update existing configuration - files by merging new modules with existing ones. - -.PARAMETER Config - The path to the YAML configuration file to read from and write to. - This parameter is mandatory and specifies both the input and output file unless OutFile is specified. - -.PARAMETER OutFile - The path to save the updated YAML configuration. - Optional parameter that allows saving to a different file than the input Config file. - -.PARAMETER DryRun - Switch parameter that prevents writing to files and displays the resulting configuration to the console. - Useful for previewing changes before committing them to a file. - -.OUTPUTS - [System.Boolean] - Returns $true if the export completes successfully or if no modules are found. - Returns $false if there are errors during the export process. - -.EXAMPLE - Export-InstalledPowershellModules -Config "environment.yaml" - - Exports installed PowerShell modules to the existing environment.yaml configuration file. - -.EXAMPLE - Export-InstalledPowershellModules -Config "current.yaml" -OutFile "backup.yaml" - - Reads from current.yaml and saves the updated configuration with installed modules to backup.yaml. - -.EXAMPLE - Export-InstalledPowershellModules -Config "dev-env.yaml" -DryRun - - Shows what the configuration would look like without actually saving to file. - -.NOTES - - Requires administrator privileges to access all installed modules - - Uses Get-InstalledModule to retrieve module information from PowerShell Gallery - - Automatically skips core dependency modules listed in the DevSetup manifest - - Handles both CurrentUser and AllUsers scope modules using path analysis - - Merges with existing YAML configuration, preserving other sections - - Supports both simple string format and complex object format for modules - - Updates existing modules when versions have changed - - Converts string entries to hashtable format when additional properties are needed - - Tracks installation scope (CurrentUser/AllUsers) for each module - - Creates the devsetup.dependencies.powershell structure if it doesn't exist - - Provides detailed console output with color-coded status messages - - Includes comprehensive error handling for module scanning and file operations - - Preserves existing module properties while updating changed values - -.LINK - -.COMPONENT - DevSetup.Providers.PowerShell - -.FUNCTIONALITY - Configuration Export, Module Discovery, YAML Generation -#> - -Function Export-InstalledPowershellModules { - [CmdletBinding()] - Param( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string]$Config, - [Parameter(Mandatory = $false)] - [ValidateNotNullOrEmpty()] - [string]$OutFile, - [switch]$DryRun - ) - - try { - # Check if running as administrator - if (-not (Test-RunningAsAdmin)) { - throw "This operation requires administrator privileges. Please run as administrator." - } - - # Get installed PowerShell modules - Write-Host "- Getting list of installed PowerShell modules..." -ForegroundColor Gray - $installedModules = Get-InstalledModule -ErrorAction SilentlyContinue - - if (-not $installedModules) { - Write-Warning "No PowerShell modules found or PowerShellGet is not available." - return $true - } - - $powershellModules = @() - - # Get core dependency modules to skip from DevSetup manifest - $manifest = Get-DevSetupManifest - $coreModulesToSkip = @() - if ($manifest -and $manifest.RequiredModules) { - $coreModulesToSkip = $manifest.RequiredModules | ForEach-Object { - if ($_ -is [string]) { - $_ - } elseif ($_ -is [hashtable] -and $_.ModuleName) { - $_.ModuleName - } elseif ($_ -is [hashtable] -and $_.name) { - $_.name - } - } - } - - foreach ($module in $installedModules) { - # Skip core dependency modules - if ($module.Name -in $coreModulesToSkip) { - Write-Verbose "Skipping core dependency module: $($module.Name)" - continue - } - - # Get module scope information - $moduleInfo = Get-Module -Name $module.Name -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1 - - # Check if module is in CurrentUser or AllUsers scope - $modulePath = $moduleInfo.ModuleBase - $scope = "Unknown" - - if ($modulePath -like "*\WindowsPowerShell\Modules\*" -or $modulePath -like "*\PowerShell\Modules\*") { - if ($modulePath -like "*$env:USERPROFILE*") { - $scope = "CurrentUser" - } else { - $scope = "AllUsers" - } - } - - if ($scope -eq "CurrentUser" -or $scope -eq "AllUsers") { - Write-Debug "Found module: $($module.Name) (version: $($module.Version), scope: $scope)" - $powershellModules += @{ - name = $module.Name - version = $module.Version.ToString() - scope = $scope - } - } else { - Write-Verbose "Skipping module with unknown scope: $($module.Name)" - } - } - - Write-Debug " - Found $($powershellModules.Count) PowerShell modules in CurrentUser or AllUsers scope (excluding core dependencies)" - - # Read existing YAML configuration - $YamlData = Read-ConfigurationFile -Config $Config - - # Ensure powershellModules section exists - if (-not $YamlData.devsetup) { $YamlData.devsetup = @{} } - if (-not $YamlData.devsetup.dependencies) { $YamlData.devsetup.dependencies = @{} } - if (-not $YamlData.devsetup.dependencies.powershell) { $YamlData.devsetup.dependencies.powershell = @{} } - if (-not $YamlData.devsetup.dependencies.powershell.modules) { $YamlData.devsetup.dependencies.powershell.modules = @() } - - # Add modules to YAML data - foreach ($module in $powershellModules) { - # Check if module already exists - $existingModule = $YamlData.devsetup.dependencies.powershell.modules | Where-Object { - ($_ -is [string] -and $_ -eq $module.name) -or - ($_ -is [hashtable] -and $_.name -eq $module.name) - } - - if (-not $existingModule) { - Write-Host " - Adding module: $($module.name) ($($module.version), $($module.scope))" -ForegroundColor Gray - $YamlData.devsetup.dependencies.powershell.modules += @{ - name = $module.name - minimumVersion = $module.version - scope = $module.scope - } - } else { - # Module exists, check if version has changed - $existingVersion = $null - if ($existingModule -is [hashtable] -and $existingModule.minimumVersion) { - $existingVersion = $existingModule.minimumVersion - } elseif ($existingModule -is [hashtable] -and $existingModule.version) { - $existingVersion = $existingModule.version - } - - if ($existingVersion -and $existingVersion -ne $module.version) { - Write-Host " - Updating module: $($module.name) ($existingVersion -> $($module.version))" -ForegroundColor Gray - - # Find index and update - $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) - - # Preserve existing module structure but update version - if ($existingModule -is [string]) { - # Convert string to hashtable with version - $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ - name = $module.name - minimumVersion = $module.version - scope = $module.scope - } - } else { - # Update existing hashtable - $YamlData.devsetup.dependencies.powershell.modules[$index].minimumVersion = $module.version - if (-not $existingModule.scope) { - $YamlData.devsetup.dependencies.powershell.modules[$index].scope = $module.scope - } - } - } elseif (-not $existingVersion) { - Write-Host " - Updating module version: $($module.name)" -ForegroundColor Gray - - # Find index and add version - $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) - - if ($existingModule -is [string]) { - # Convert string to hashtable with version - $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ - name = $module.name - minimumVersion = $module.version - scope = $module.scope - } - } else { - # Add version to existing hashtable - $YamlData.devsetup.dependencies.powershell.modules[$index].minimumVersion = $module.version - if (-not $existingModule.scope) { - $YamlData.devsetup.dependencies.powershell.modules[$index].scope = $module.scope - } - } - } else { - Write-Host " - Skipping module (No Change): $($module.name) ($($module.version))" -ForegroundColor Gray - } - } - } - - # Convert to YAML - try { - $yamlOutput = $YamlData | ConvertTo-Yaml - } - catch { - Write-Warning "Could not convert to YAML format. Showing PowerShell object instead:" - $yamlOutput = $YamlData | ConvertTo-Json -Depth 10 - } - - # Handle output based on parameters - if ($DryRun) { - Write-Host "`nDry Run - Configuration would be saved as:" -ForegroundColor Cyan - Write-Host $yamlOutput -ForegroundColor White - Write-Host "`nNo files were modified (dry run mode)." -ForegroundColor Yellow - } else { - # Determine output file - $outputFile = if ($OutFile) { $OutFile } else { $Config } - - try { - Write-Debug "`nSaving configuration to: $outputFile" - $yamlOutput | Out-File -FilePath $outputFile - Write-Debug "Configuration saved successfully!" - } - catch { - Write-Error "Failed to save configuration to $outputFile`: $_" - return $false - } - } - - Write-Host "PowerShell modules conversion completed!" -ForegroundColor Green - return $true - } - catch { - Write-Error "Error converting PowerShell modules: $_" - return $false - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.Tests.ps1 new file mode 100644 index 0000000..96665bd --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.Tests.ps1 @@ -0,0 +1,394 @@ +BeforeAll { + . $PSScriptRoot\Get-PowershellModuleScopeMap.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 +} + +Describe "Get-PowershellModuleScopeMap" { + + Context "When running on Windows" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + } + + It "Should use USERPROFILE as search path and correctly map CurrentUser scope" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + + It "Should handle PowerShell 7 module paths on Windows" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules" + $ps7Path = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + $ps5Path = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$ps7Path$([System.IO.Path]::PathSeparator)$ps5Path" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules" + $expectedPs7Path = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + $expectedPs5Path = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedPs7Path + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedPs5Path + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle mixed user profile paths correctly" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { + $oneDrivePath = Join-Path $TestDrive "Users" "TestUser" "OneDrive" "Documents" "PowerShell" "Modules" + $regularPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + return "$oneDrivePath$([System.IO.Path]::PathSeparator)$regularPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedOneDrivePath = Join-Path $TestDrive "Users" "TestUser" "OneDrive" "Documents" "PowerShell" "Modules" + $expectedRegularPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedOneDrivePath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedRegularPath + $result[1].Scope | Should -Be "CurrentUser" + $result[2].Path | Should -Be $expectedSystemPath + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle empty PSModulePath" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { return "" } + } + } + + $result = Get-PowershellModuleScopeMap + + # When PSModulePath is empty, filtering removes empty entries + $result | Should -HaveCount 0 + } + } + + Context "When running on Linux" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $false + } + } + + It "Should use HOME as search path and correctly map CurrentUser scope" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "home" "testuser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $systemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $systemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath1$([System.IO.Path]::PathSeparator)$systemPath2" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $expectedSystemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $expectedSystemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath1 + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedSystemPath2 + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle custom user paths on Linux" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "home" "testuser") } + "PSModulePath" { + $customPath = Join-Path $TestDrive "home" "testuser" "custom" "powershell" "modules" + $userPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $systemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + return "$customPath$([System.IO.Path]::PathSeparator)$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedCustomPath = Join-Path $TestDrive "home" "testuser" "custom" "powershell" "modules" + $expectedUserPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedCustomPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedUserPath + $result[1].Scope | Should -Be "CurrentUser" + $result[2].Path | Should -Be $expectedSystemPath + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle root user paths correctly" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "root") } + "PSModulePath" { + $rootPath = Join-Path $TestDrive "root" ".local" "share" "powershell" "Modules" + $systemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + return "$rootPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedRootPath = Join-Path $TestDrive "root" ".local" "share" "powershell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedRootPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + } + + Context "When running on macOS" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $false + } + } + + It "Should use HOME as search path and correctly map CurrentUser scope" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "Users" "testuser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $systemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $systemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath1$([System.IO.Path]::PathSeparator)$systemPath2" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $expectedSystemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $expectedSystemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath1 + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedSystemPath2 + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle Homebrew installed PowerShell paths" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "Users" "testuser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $homebrewPath = Join-Path $TestDrive "opt" "homebrew" "share" "powershell" "Modules" + $systemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$homebrewPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $expectedHomebrewPath = Join-Path $TestDrive "opt" "homebrew" "share" "powershell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedHomebrewPath + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedSystemPath + $result[2].Scope | Should -Be "AllUsers" + } + } + + Context "Edge cases and special characters" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + } + + It "Should handle paths with special characters" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "Test User (Admin)") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "Test User (Admin)" "Documents" "PowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "Test User (Admin)" "Documents" "PowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + + It "Should handle paths with regex special characters" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "Test.User[1]") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "Test.User[1]" "Documents" "PowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "Test.User[1]" "Documents" "PowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + + It "Should handle single path entry" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { return (Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules") } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules" + + $result | Should -HaveCount 1 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + } + } + + Context "Error scenarios" { + It "Should handle null PSModulePath gracefully" { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { return $null } + } + } + + $result = Get-PowershellModuleScopeMap + + # When PSModulePath is null, filtering removes null/empty entries + $result | Should -HaveCount 0 + } + + It "Should handle null USERPROFILE/HOME gracefully" { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return $null } + "PSModulePath" { return (Join-Path $TestDrive "Program Files" "PowerShell" "Modules") } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedSystemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + + $result | Should -HaveCount 1 + $result[0].Path | Should -Be $expectedSystemPath + $result[0].Scope | Should -Be "AllUsers" + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.ps1 b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.ps1 new file mode 100644 index 0000000..c4b2c5a --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.ps1 @@ -0,0 +1,20 @@ +Function Get-PowershellModuleScopeMap { + [CmdletBinding()] + [OutputType([array])] + Param() + + if((Test-OperatingSystem -Windows)) { + $SearchPath = (Get-EnvironmentVariable USERPROFILE) + } else { + $SearchPath = (Get-EnvironmentVariable HOME) + } + + $InstallPaths = @( + (Get-EnvironmentVariable PSModulePath) -split ([System.IO.Path]::PathSeparator) | Where-Object { $_ -ne $null -and $_.Trim() -ne "" } | ForEach-Object { + $scope = if($SearchPath -and ($_ -match [regex]::Escape($SearchPath))) { "CurrentUser" } else { "AllUsers" } + [PSCustomObject]@{ Path = $_; Scope = $scope } + } + ) + + return $InstallPaths +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 index 6880c74..f660814 100644 --- a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 @@ -1,11 +1,11 @@ BeforeAll { - . $PSScriptRoot\Install-PowershellModule.ps1 - . $PSScriptRoot\Test-PowershellModuleInstalled.ps1 - . $PSScriptRoot\Uninstall-PowershellModule.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - Mock Write-Error {} - Mock Write-Warning {} + . (Join-Path $PSScriptRoot "Install-PowershellModule.ps1") + . (Join-Path $PSScriptRoot "Test-PowershellModuleInstalled.ps1") + . (Join-Path $PSScriptRoot "Uninstall-PowershellModule.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + Mock Write-StatusMessage { } } Describe "Install-PowershellModule" { @@ -18,6 +18,23 @@ Describe "Install-PowershellModule" { } } + Context "When Test-RunningAsAdmin throws an exception" { + It "Should return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $result = Install-PowershellModule -ModuleName "Az" -Scope "AllUsers" + $result | Should -Be $false + } + } + + Context "When Test-PowershellModuleInstalled throws an exception" { + It "Should return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-PowershellModuleInstalled { throw "Module test failed" } + $result = Install-PowershellModule -ModuleName "Az" + $result | Should -Be $false + } + } + Context "When module is already installed with correct version and scope" { It "Should return true and not call Uninstall-PowershellModule or Install-Module" { Mock Test-RunningAsAdmin { return $true } @@ -39,21 +56,10 @@ Describe "Install-PowershellModule" { } $script:uninstallCalled = $false Mock Uninstall-PowershellModule -MockWith { - param( - [string]$ModuleName, - [string]$Scope - ) $script:uninstallCalled = $true } $script:installCalled = $false Mock Install-Module -MockWith { - param( - [string]$Name, - [switch]$Force, - [string]$Scope, - [switch]$AllowClobber, - [string]$RequiredVersion - ) $script:installCalled = $true } $result = Install-PowershellModule -ModuleName "Az" @@ -71,13 +77,6 @@ Describe "Install-PowershellModule" { } $script:installCalled = $false Mock Install-Module -MockWith { - param( - [string]$Name, - [switch]$Force, - [string]$Scope, - [switch]$AllowClobber, - [string]$RequiredVersion - ) $script:installCalled = $true } $result = Install-PowershellModule -ModuleName "Az" @@ -151,4 +150,38 @@ Describe "Install-PowershellModule" { $installParams.RequiredVersion | Should -Be "9.0.1" } } + + Context "When WhatIf is specified" { + It "Should return true and not install the module" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-PowershellModuleInstalled { + return [InstalledState]::NotInstalled + } + Mock Install-Module { throw "Should not be called" } + $result = Install-PowershellModule -ModuleName "Az" -WhatIf + $result | Should -Be $true + } + } + + Context "When module is installed with CurrentUser scope by default" { + It "Should use CurrentUser scope when no scope is specified" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-PowershellModuleInstalled { + return [InstalledState]::NotInstalled + } + Mock Install-Module -MockWith { + param( + [string]$Name, + [string]$Scope + ) + $script:installParams = @{ + ModuleName = $Name + Scope = $Scope + } + } + $result = Install-PowershellModule -ModuleName "Az" + $result | Should -Be $true + $installParams.Scope | Should -Be "CurrentUser" + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 index bd4b9ce..f7f2937 100644 --- a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 +++ b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 @@ -77,7 +77,7 @@ #> Function Install-PowershellModule { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] @@ -97,51 +97,71 @@ Function Install-PowershellModule { [ValidateSet('CurrentUser', 'AllUsers')] [String] $Scope = 'CurrentUser' ) - + try { # Check if running as administrator only when installing for all users if ($Scope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { - throw "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or use CurrentUser scope." - } - - $installParams = @{ - Name = $ModuleName - Force = $Force - Scope = $Scope - AllowClobber = $AllowClobber - SkipPublisherCheck = $true + Write-StatusMessage "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or use CurrentUser scope." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + $installParams = @{ + Name = $ModuleName + Force = $Force + Scope = $Scope + AllowClobber = $AllowClobber + SkipPublisherCheck = $true + } - $testParams = @{ - ModuleName = $ModuleName - Scope = $Scope - } + $testParams = @{ + ModuleName = $ModuleName + Scope = $Scope + } - if($PSBoundParameters.ContainsKey('Version')) { - $testParams.Version = $Version - $installParams.RequiredVersion = $Version - } + if($PSBoundParameters.ContainsKey('Version')) { + $testParams.Version = $Version + $installParams.RequiredVersion = $Version + } + try { $testResult = Test-PowershellModuleInstalled @testParams + } catch { + Write-StatusMessage "Failed to test if PowerShell module is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - if($testResult.HasFlag([InstalledState]::Pass)) { - return $true - } + if($testResult.HasFlag([InstalledState]::Pass)) { + return $true + } - if($testResult.HasFlag([InstalledState]::Installed)) { - try { - Uninstall-PowershellModule -ModuleName $ModuleName - } catch { - # Uninstall might have failed, we keep going anyways - Write-Debug "Failed to uninstall existing module '$ModuleName': $_" - } + if($testResult.HasFlag([InstalledState]::Installed)) { + try { + Uninstall-PowershellModule -ModuleName $ModuleName -WhatIf:$WhatIf + } catch { + # Uninstall might have failed, we keep going anyways + Write-StatusMessage "Failed to uninstall existing module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error } + } - # Install the PowerShell module - Install-Module @installParams + # Install the PowerShell module + if ($PSCmdlet.ShouldProcess($ModuleName, "Install-Module")) { + try { + Install-Module @installParams + } catch { + Write-StatusMessage "Failed to install PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + } else { + Write-StatusMessage "Installation of module '$ModuleName' was skipped due to ShouldProcess." -Verbosity Warning return $true } - catch { - return $false - } + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Install-PowershellModules.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Install-PowershellModules.Tests.ps1 deleted file mode 100644 index f497f86..0000000 --- a/DevSetup/Private/Providers/Powershell/Install-PowershellModules.Tests.ps1 +++ /dev/null @@ -1,170 +0,0 @@ -BeforeAll { - . (Join-Path $PSScriptRoot "Install-PowershellModules.ps1") - . (Join-Path $PSScriptRoot "Install-PowershellModule.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") - Mock Write-StatusMessage { } - Mock Test-RunningAsAdmin { return $true } - Mock Write-Error {} - Mock Write-Warning {} - Mock Write-Host {} -} - -Describe "Install-PowershellModules" { - - Context "When YAML configuration is missing PowerShell modules" { - It "Should return false" { - $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ } } } } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When YAML configuration is missing dependencies" { - It "Should return false" { - $yamlData = @{ devsetup = @{ } } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When AllUsers scope is specified but not running as admin" { - It "Should return false" { - Mock Test-RunningAsAdmin { return $false } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - scope = "AllUsers" - modules = @("posh-git") - } - } - } - } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When modules are installed successfully (string format)" { - It "Should install all modules and return true" { - $script:installCalls = @() - Mock Install-PowershellModule -MockWith { - param($ModuleName, $Force, $AllowClobber, $Scope, $Version) - $script:installCalls += $ModuleName - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @("posh-git", "PSReadLine") - } - } - } - } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls | Should -Contain "PSReadLine" - } - } - - Context "When modules are installed successfully (object format)" { - It "Should install all modules and return true" { - $script:installCalls = @() - Mock Install-PowershellModule -MockWith { - param($ModuleName, $Force, $AllowClobber, $Scope, $Version) - $script:installCalls += $ModuleName - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @( - @{ name = "posh-git"; minimumVersion = "1.0.0"; scope = "CurrentUser"; force = $true; allowClobber = $true }, - @{ name = "PSReadLine"; minimumVersion = "2.2.6"; scope = "AllUsers"; force = $false; allowClobber = $false } - ) - } - } - } - } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls | Should -Contain "PSReadLine" - } - } - - Context "When some modules fail to install" { - It "Should continue and return true" { - $script:installCalls = @() - Mock Install-PowershellModule -MockWith { - param($ModuleName, $Force, $AllowClobber, $Scope, $Version) - $script:installCalls += $ModuleName - if ($ModuleName -eq "PSReadLine") { return $false } - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @("posh-git", "PSReadLine", "PowerShellGet") - } - } - } - } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls | Should -Contain "PSReadLine" - $installCalls | Should -Contain "PowerShellGet" - } - } - - Context "When module entry is empty or missing name" { - It "Should skip invalid entries and return true" { - $script:installCalls = @() - Mock Install-PowershellModule -MockWith { - param($ModuleName, $Force, $AllowClobber, $Scope, $Version) - $script:installCalls += $ModuleName - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @( - $null, - @{ minimumVersion = "1.0.0" }, - "posh-git" - ) - } - } - } - } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls.Count | Should -Be 1 - } - } - - Context "When an exception occurs during installation" { - It "Should catch and return false" { - Mock Install-PowershellModule { throw "Unexpected error" } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @("posh-git") - } - } - } - } - $result = Install-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 new file mode 100644 index 0000000..911deac --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 @@ -0,0 +1,265 @@ +BeforeAll { + function Write-EZLog {} + . (Join-Path $PSScriptRoot "Invoke-PowershellModulesExport.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Update-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Get-DevSetupManifest.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "Get-PowershellModuleScopeMap.ps1") + + Mock Test-RunningAsAdmin { $true } + Mock Get-InstalledModule { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + @( + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = (Join-Path $userPath "ModuleA") }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") } + ) + } + Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } + Mock Get-PowershellModuleScopeMap { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + @( + @{ Path = $userPath; Scope = "CurrentUser" }, + @{ Path = $systemPath; Scope = "AllUsers" } + ) + } + Mock Get-Module { + param($Name) + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + @{ Name = $Name; ModuleBase = (Join-Path $userPath $Name); Version = [version]"1.0.0" } + } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "CurrentUser" } } } } } + Mock Update-DevSetupEnvFile { } + Mock Write-Host { } + Mock Write-Warning { } + Mock Write-Error { } + Mock Write-Debug { } + Mock Write-Verbose { } + Mock Write-StatusMessage { } +} + +Describe "Invoke-PowershellModulesExport" { + + Context "When not running as administrator" { + It "Should throw and return false" { + Mock Test-RunningAsAdmin { $false } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "requires administrator privileges" -and $Verbosity -eq "Error" } + } + } + + Context "When Test-RunningAsAdmin throws an exception" { + It "Should return false and log error" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to validate administrator privileges" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-DevSetupManifest throws an exception" { + It "Should return false and log error" { + Mock Get-DevSetupManifest { throw "Manifest read failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to read DevSetup manifest" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-PowershellModuleScopeMap throws an exception" { + It "Should return false and log error" { + Mock Get-PowershellModuleScopeMap { throw "Scope map failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to get PowerShell module scope map" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-PowershellModuleScopeMap returns empty" { + It "Should warn and return true" { + Mock Get-PowershellModuleScopeMap { @() } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeTrue + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "No PowerShell module install paths found" -and $Verbosity -eq "Warning" } + } + } + + Context "When Read-DevSetupEnvFile throws an exception" { + It "Should return false and log error" { + Mock Read-DevSetupEnvFile { throw "Config read failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to read configuration file" -and $Verbosity -eq "Error" } + } + } + + Context "When no modules are found" { + It "Should warn and return true" { + Mock Get-InstalledModule { @() } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeTrue + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "No PowerShell modules found" -and $Verbosity -eq "Warning" } + } + } + + Context "When core dependency modules are present" { + It "Should skip core dependency modules" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Get-InstalledModule { @( + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = (Join-Path $userPath "ModuleA") }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") } + ) } + Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Adding module: ModuleB" } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -notmatch "Adding module: ModuleA" } + } + } + + Context "When core dependency modules are hashtable format" { + It "Should skip hashtable format core dependency modules" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Get-InstalledModule { @( + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = (Join-Path $userPath "ModuleA") }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") } + ) } + Mock Get-DevSetupManifest { @{ RequiredModules = @(@{ ModuleName = "ModuleA"; ModuleVersion = "1.0.0" }) } } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Adding module: ModuleB" } + } + } + + Context "When modules are found and added to config" { + It "Should add new modules to YAML data" { + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeTrue + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Adding module: ModuleB" } + } + } + + Context "When module version changes" { + It "Should update the module version in the config" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; minimumVersion = "1.0.0"; scope = "CurrentUser" }); scope = "CurrentUser" } } } } } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module: ModuleB" } + } + } + + Context "When module exists but has no version" { + It "Should add minimumVersion to the module" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; scope = "CurrentUser" }); scope = "CurrentUser" } } } } } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module version: ModuleB" } + } + } + + Context "When module is unchanged" { + It "Should skip updating the module" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { + @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @(@{ + name = "ModuleB"; + minimumVersion = "2.0.0"; + scope = "CurrentUser" + }); + scope = "CurrentUser" + } + } + } + } + } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } + Mock Get-DevSetupManifest { @{ RequiredModules = @() } } # No core dependencies to exclude ModuleB + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Skipping module.*No Change.*ModuleB" } + } + } + + Context "When module exists with version property instead of minimumVersion" { + It "Should use version property for comparison and detect change" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; version = "1.0.0"; scope = "CurrentUser" }); scope = "CurrentUser" } } } } } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module: ModuleB \(1\.0\.0 -> 2\.0\.0\)" } + } + } + + Context "When module has unknown scope" { + It "Should skip module with unknown installation location" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + $unknownPath = Join-Path $TestDrive "Some" "Unknown" "Path" "UnknownModule" + + Mock Get-InstalledModule { @(@{ Name = "UnknownModule"; Version = [version]"1.0.0"; InstalledLocation = $unknownPath }) } + Mock Get-PowershellModuleScopeMap { @( + @{ Path = $userPath; Scope = "CurrentUser" }, + @{ Path = $systemPath; Scope = "AllUsers" } + ) } + Mock Get-DevSetupManifest { @{ RequiredModules = @() } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "UnknownScope" } } } } } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Skipping module with unknown scope: UnknownModule" -and $Verbosity -eq "Verbose" } + } + } + + Context "When module scope differs from default scope" { + It "Should override default scope with detected scope from installation path" { + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemModulePath = Join-Path $systemPath "SystemModule" + + Mock Get-InstalledModule { @( + @{ Name = "SystemModule"; Version = [version]"1.0.0"; InstalledLocation = $systemModulePath } + ) } + Mock Get-PowershellModuleScopeMap { @( + @{ Path = $userPath; Scope = "CurrentUser" }, + @{ Path = $systemPath; Scope = "AllUsers" } + ) } + Mock Get-DevSetupManifest { @{ RequiredModules = @() } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "CurrentUser" } } } } } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Found module: SystemModule.*scope: AllUsers" } + } + } + + Context "When DryRun is used" { + It "Should call Update-DevSetupEnvFile with -WhatIf and not write to file" { + $result = Invoke-PowershellModulesExport -Config "test.yaml" -DryRun + $result | Should -BeTrue + Assert-MockCalled Update-DevSetupEnvFile -Times 1 -Scope It + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Configuration saved successfully!" } + } + } + + + + Context "When Out-File fails" { + It "Should write error and return false" { + Mock Update-DevSetupEnvFile { throw "File error" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to save configuration" -and $Verbosity -eq "Error"} + } + } + + Context "When an unexpected error occurs during module retrieval" { + It "Should write error and return false" { + Mock Get-InstalledModule { throw "Unexpected error" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to retrieve installed PowerShell modules" -and $Verbosity -eq "Error"} + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 new file mode 100644 index 0000000..39b4a34 --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 @@ -0,0 +1,249 @@ +๏ปฟ<# +.SYNOPSIS + Exports installed PowerShell modules to a YAML configuration file. + +.DESCRIPTION + This function scans the system for installed PowerShell modules and exports them to a YAML + configuration file in DevSetup format. It uses Get-InstalledModule to retrieve comprehensive + module information including versions and installation scope. The function intelligently skips + core dependency modules defined in the DevSetup manifest and can update existing configuration + files by merging new modules with existing ones. + +.PARAMETER Config + The path to the YAML configuration file to read from and write to. + This parameter is mandatory and specifies both the input and output file unless OutFile is specified. + +.PARAMETER OutFile + The path to save the updated YAML configuration. + Optional parameter that allows saving to a different file than the input Config file. + +.PARAMETER DryRun + Switch parameter that prevents writing to files and displays the resulting configuration to the console. + Useful for previewing changes before committing them to a file. + +.OUTPUTS + [System.Boolean] + Returns $true if the export completes successfully or if no modules are found. + Returns $false if there are errors during the export process. + +.EXAMPLE + Invoke-PowershellModulesExport -Config "environment.yaml" + + Exports installed PowerShell modules to the existing environment.yaml configuration file. + +.EXAMPLE + Invoke-PowershellModulesExport -Config "current.yaml" -OutFile "backup.yaml" + + Reads from current.yaml and saves the updated configuration with installed modules to backup.yaml. + +.EXAMPLE + Invoke-PowershellModulesExport -Config "dev-env.yaml" -DryRun + + Shows what the configuration would look like without actually saving to file. + +.NOTES + - Requires administrator privileges to access all installed modules + - Uses Get-InstalledModule to retrieve module information from PowerShell Gallery + - Automatically skips core dependency modules listed in the DevSetup manifest + - Handles both CurrentUser and AllUsers scope modules using path analysis + - Merges with existing YAML configuration, preserving other sections + - Supports both simple string format and complex object format for modules + - Updates existing modules when versions have changed + - Converts string entries to hashtable format when additional properties are needed + - Tracks installation scope (CurrentUser/AllUsers) for each module + - Creates the devsetup.dependencies.powershell structure if it doesn't exist + - Provides detailed console output with color-coded status messages + - Includes comprehensive error handling for module scanning and file operations + - Preserves existing module properties while updating changed values + +.LINK + +.COMPONENT + DevSetup.Providers.PowerShell + +.FUNCTIONALITY + Configuration Export, Module Discovery, YAML Generation +#> + +Function Invoke-PowershellModulesExport { + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Config, + [switch]$DryRun + ) + + try { + # Check if running as administrator + if (-not (Test-RunningAsAdmin)) { + Write-StatusMessage "This operation requires administrator privileges. Please run as administrator." -Verbosity Error + return $false + } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + # Get installed PowerShell modules + Write-StatusMessage "- Getting list of installed PowerShell modules..." -ForegroundColor Gray + try { + $installedModules = Get-InstalledModule -ErrorAction SilentlyContinue + } catch { + Write-StatusMessage "Failed to retrieve installed PowerShell modules: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + if (-not $installedModules) { + Write-StatusMessage "No PowerShell modules found or PowerShellGet is not available." -Verbosity Warning + return $true + } + + $powershellModules = @() + + # Get core dependency modules to skip from DevSetup manifest + try { + $manifest = Get-DevSetupManifest + } catch { + Write-StatusMessage "Failed to read DevSetup manifest: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + # Valid formats for core modules in manifest: + # @('ModuleName1', 'ModuleName2') + # or + # @(@{ ModuleName = 'ModuleName1'; ModuleVersion = '1.0.0' }, @{ name = 'ModuleName2'; RequiredVersion = '2.0.0' }) + # In the second version, ModuleVersion and RequiredVersion are mutually exclusive + # and only one should be used per module entry. + + $coreModulesToSkip = @() + if ($manifest -and $manifest.RequiredModules) { + $coreModulesToSkip = $manifest.RequiredModules | ForEach-Object { + if ($_ -is [string]) { + $_ + } elseif ($_ -is [hashtable] -and $_.ModuleName) { + $_.ModuleName + } + } + } + + try { + $InstallPaths = Get-PowershellModuleScopeMap + } catch { + Write-StatusMessage "Failed to get PowerShell module scope map: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + if(-not $InstallPaths -or $InstallPaths.Count -eq 0) { + Write-StatusMessage "No PowerShell module install paths found." -Verbosity Warning + return $true + } + + try { + $YamlData = Read-DevSetupEnvFile -Config $Config + } catch { + Write-StatusMessage "Failed to read configuration file $Config`: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + foreach ($module in $installedModules) { + # Skip core dependency modules + if ($module.Name -in $coreModulesToSkip) { + Write-StatusMessage "Skipping core dependency module: $($module.Name)" -Verbosity Verbose + continue + } + + $moduleScope = ($InstallPaths | ForEach-Object { + if ($module.InstalledLocation -like "$($_.Path)$([System.IO.Path]::DirectorySeparatorChar)*") { + $_.Scope + } + }) + + if ($moduleScope -eq "CurrentUser" -or $moduleScope -eq "AllUsers") { + Write-StatusMessage "Found module: $($module.Name) (version: $($module.Version), scope: $moduleScope)" -Verbosity Debug + $powershellModules += @{ + name = $module.Name + version = $module.Version.ToString() + scope = $moduleScope + } + } else { + Write-StatusMessage "Skipping module with unknown scope: $($module.Name)" -Verbosity Verbose + } + } + + Write-StatusMessage " - Found $($powershellModules.Count) PowerShell modules in CurrentUser or AllUsers scope (excluding core dependencies)" -Verbosity Debug + + # Add modules to YAML data + foreach ($module in $powershellModules) { + # Check if module already exists + $existingModule = $YamlData.devsetup.dependencies.powershell.modules | Where-Object { + ($_.name -eq $module.name) + } + + if (-not $existingModule) { + Write-StatusMessage "- Adding module: $($module.name) ($($module.version), $($module.scope))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + $YamlData.devsetup.dependencies.powershell.modules += @{ + name = $module.name + minimumVersion = $module.version + version = "" + scope = $module.scope + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + # Module exists, check if version has changed + $existingVersion = $null + if (-not ([string]::IsNullOrEmpty($existingModule.minimumVersion))) { + $existingVersion = $existingModule.minimumVersion + } elseif (-not ([string]::IsNullOrEmpty($existingModule.version))) { + $existingVersion = $existingModule.version + } + + if ($existingVersion -and $existingVersion -ne $module.version) { + Write-StatusMessage "- Updating module: $($module.name) ($existingVersion -> $($module.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + + # Find index and update + $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) + $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ + name = $module.name + minimumVersion = $module.version + scope = $module.scope + version = "" + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } elseif (-not $existingVersion) { + Write-StatusMessage "- Updating module version: $($module.name)" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + + $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) + $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ + name = $module.name + minimumVersion = $module.version + scope = $module.scope + version = "" + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + Write-StatusMessage "- Skipping module (No Change): $($module.name) ($($module.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + Write-StatusMessage "[OK]" -ForegroundColor Green + } + } + } + + try { + Write-StatusMessage "`nSaving configuration to: $Config" -Verbosity Debug + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun + Write-StatusMessage "Configuration saved successfully!" -Verbosity Debug + } + catch { + Write-StatusMessage "Failed to save configuration to $Config`: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "PowerShell modules conversion completed!" -ForegroundColor Green + return $true +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.Tests.ps1 new file mode 100644 index 0000000..6bec694 --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.Tests.ps1 @@ -0,0 +1,253 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-PowershellModulesInstall.ps1") + . (Join-Path $PSScriptRoot "Install-PowershellModule.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") + Mock Write-StatusMessage { } + Mock Test-RunningAsAdmin { return $true } + Mock Write-Error {} + Mock Write-Warning {} + Mock Write-Host {} + Mock Install-PowershellModule { return $true } +} + +Describe "Invoke-PowershellModulesInstall" { + + Context "When YAML configuration is missing PowerShell modules" { + It "Should return true (handles empty module list gracefully)" { + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @() } } } } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + } + } + + Context "When YAML configuration is missing dependencies" { + It "Should return true (handles missing dependencies gracefully)" { + $yamlData = @{ devsetup = @{ } } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + } + } + + Context "When AllUsers scope is specified but not running as admin" { + It "Should return false and show admin error" { + Mock Test-RunningAsAdmin { return $false } + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "AllUsers" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "administrator privileges" } + } + } + + Context "When Test-RunningAsAdmin throws exception" { + It "Should return false and log error" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "AllUsers" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Failed to validate administrator privileges" } + } + } + + Context "When modules are installed successfully (object format)" { + It "Should install all modules and return true" { + Mock Install-PowershellModule -MockWith { + param($ModuleName, $Force, $AllowClobber, $Scope, $Version, $WhatIf) + return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git"; minimumVersion = "1.0.0"; scope = "CurrentUser"; force = $true; allowClobber = $true }, + @{ name = "PSReadLine"; minimumVersion = "2.2.6"; scope = "AllUsers"; force = $false; allowClobber = $false } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } + } + } + + Context "When modules use default scope and settings" { + It "Should use global scope and default force/allowClobber settings" { + Mock Install-PowershellModule -MockWith { + param($ModuleName, $Force, $AllowClobber, $Scope, $Version, $WhatIf) + return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "CurrentUser" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -ParameterFilter { + $ModuleName -eq "posh-git" -and + $Scope -eq "CurrentUser" -and + $Force -eq $true -and + $AllowClobber -eq $true + } + } + } + + Context "When module has no version specified" { + It "Should install latest version" { + Mock Install-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "CurrentUser" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -match "latest version" } + } + } + + Context "When some modules fail to install" { + It "Should continue and return true" { + Mock Install-PowershellModule -MockWith { + param($ModuleName) + if ($ModuleName -eq "PSReadLine") { return $false } + return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" }, + @{ name = "PSReadLine" }, + @{ name = "PowerShellGet" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "PowerShellGet" } + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" } + } + } + + Context "When module entry is null" { + It "Should skip null entries and return true" { + Mock Install-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + $null, + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -Times 1 + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + } + } + + Context "When an exception occurs during installation" { + It "Should catch exception, continue, and return true" { + Mock Install-PowershellModule { + param($ModuleName) + if ($ModuleName -eq "ErrorModule") { throw "Installation error" } + return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "ErrorModule" }, + @{ name = "GoodModule" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Error installing PowerShell module ErrorModule" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "ErrorModule" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "GoodModule" } + } + } + + Context "When DryRun is specified" { + It "Should pass WhatIf to Install-PowershellModule" { + Mock Install-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData -DryRun + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -ParameterFilter { $WhatIf -eq $true } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Install-PowershellModules.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.ps1 similarity index 54% rename from DevSetup/Private/Providers/Powershell/Install-PowershellModules.ps1 rename to DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.ps1 index bfb1c47..d93fd0b 100644 --- a/DevSetup/Private/Providers/Powershell/Install-PowershellModules.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.ps1 @@ -21,7 +21,7 @@ .EXAMPLE $yamlData = Get-Content "config.yaml" | ConvertFrom-Yaml - Install-PowershellModules -YamlData $yamlData + Invoke-PowershellModulesInstall -YamlData $yamlData Installs PowerShell modules from a YAML configuration file. @@ -48,7 +48,7 @@ } } } - Install-PowershellModules -YamlData $yamlData + Invoke-PowershellModulesInstall -YamlData $yamlData Demonstrates the PSCustomObject structure and installs the configured modules. @@ -76,87 +76,75 @@ Bulk Installation, Configuration Processing, Module Management #> -Function Install-PowershellModules { +Function Invoke-PowershellModulesInstall { Param( [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] - [PSCustomObject]$YamlData + [PSCustomObject]$YamlData, + + [Parameter(Mandatory=$false, Position=1)] + [switch]$DryRun = $false ) + $modules = $YamlData.devsetup.dependencies.powershell.modules + + # Get global scope setting from YAML, default to CurrentUser + $globalScope = 'AllUsers' + if ($YamlData.devsetup.dependencies.powershell.scope) { + $globalScope = $YamlData.devsetup.dependencies.powershell.scope + } + try { - Write-StatusMessage "- Installing PowerShell modules from configuration:" -ForegroundColor Cyan - # Check if PowerShell modules dependencies exist - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.powershell -or -not $YamlData.devsetup.dependencies.powershell.modules) { - Write-Debug "PowerShell modules not found in YAML configuration. Skipping installation." - Write-StatusMessage "- PowerShell modules installation completed! Processed 0 modules." -ForegroundColor Green - Write-Host "" - return $false + # Check if running as administrator when global scope is AllUsers + if ($globalScope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { + throw "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "- Installing PowerShell modules from configuration:" -ForegroundColor Cyan + + $moduleCount = 0 + + foreach ($module in $modules) { + if (-not $module) { continue } - $modules = $YamlData.devsetup.dependencies.powershell.modules + # Determine scope for this module (module-specific overrides global) + $moduleScope = if ($module.scope) { $module.scope } else { $globalScope } - # Get global scope setting from YAML, default to CurrentUser - $globalScope = 'AllUsers' - if ($YamlData.devsetup.dependencies.powershell.scope) { - $globalScope = $YamlData.devsetup.dependencies.powershell.scope + # Set defaults and build parameters + $installParams = @{ + ModuleName = $module.name + Force = if ($module.force -is [bool]) { $module.force } else { $true } + AllowClobber = if ($module.allowClobber -is [bool]) { $module.allowClobber } else { $true } + Scope = $moduleScope + WhatIf = $DryRun } - # Check if running as administrator when global scope is AllUsers - if ($globalScope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { - throw "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." + if ($module.minimumVersion) { + $installParams.Version = $module.minimumVersion + Write-StatusMessage "- Installing PowerShell module: $($module.name) (version: $($module.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 + } else { + Write-StatusMessage "- Installing PowerShell module: $($module.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 } - - $moduleCount = 0 - - foreach ($module in $modules) { - if (-not $module) { continue } - - $moduleCount++ - - # Normalize module to object format - if ($module -is [string]) { - $moduleObj = @{ name = $module } - } else { - $moduleObj = $module - } - - # Validate module name - if ([string]::IsNullOrEmpty($moduleObj.name)) { - Write-Warning "Module entry #$moduleCount has no name specified, skipping" - continue - } - - # Determine scope for this module (module-specific overrides global) - $moduleScope = if ($moduleObj.scope) { $moduleObj.scope } else { $globalScope } - - # Set defaults and build parameters - $installParams = @{ - ModuleName = $moduleObj.name - Force = if ($moduleObj.force -is [bool]) { $moduleObj.force } else { $true } - AllowClobber = if ($moduleObj.allowClobber -is [bool]) { $moduleObj.allowClobber } else { $true } - Scope = $moduleScope - } - - if ($moduleObj.minimumVersion) { - $installParams.Version = $moduleObj.minimumVersion - Write-StatusMessage "- Installing PowerShell module: $($moduleObj.name) (version: $($moduleObj.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 + try { + # Attempt to install the module + if (-not (Install-PowerShellModule @installParams)) { + Write-StatusMessage "[FAILED]" -ForegroundColor Red } else { - Write-StatusMessage "- Installing PowerShell module: $($moduleObj.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 - } - - if ((Install-PowerShellModule @installParams)) { Write-StatusMessage "[OK]" -ForegroundColor Green - } else { - Write-StatusMessage "[FAILED]" -ForegroundColor Red + $moduleCount++ } + } catch { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + Write-StatusMessage "Error installing PowerShell module $($module.name): $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error } - Write-StatusMessage "- PowerShell modules installation completed! Processed $moduleCount modules." -ForegroundColor Green - Write-Host "" - return $true - } - catch { - Write-Error "Error installing PowerShell modules: $_" - return $false } + Write-StatusMessage "- PowerShell modules installation completed! Processed $moduleCount modules.`n" -ForegroundColor Green + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.Tests.ps1 new file mode 100644 index 0000000..a00e5b4 --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.Tests.ps1 @@ -0,0 +1,218 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Invoke-PowershellModulesUninstall.ps1") + . (Join-Path $PSScriptRoot "Uninstall-PowershellModule.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") + . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + Mock Write-StatusMessage { } + Mock Write-Warning { } + Mock Write-Error { } + Mock Test-RunningAsAdmin { return $true } + Mock Write-Host { } + Mock Uninstall-PowershellModule { return $true } +} + +Describe "Invoke-PowershellModulesUninstall" { + + Context "When YAML configuration is missing PowerShell modules" { + It "Should return true (handles empty module list gracefully)" { + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @() } } } } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + } + } + + Context "When YAML configuration is missing dependencies" { + It "Should return true (handles missing dependencies gracefully)" { + $yamlData = @{ devsetup = @{ } } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + } + } + + Context "When AllUsers scope is specified but not running as admin" { + It "Should return false and log admin error" { + Mock Test-RunningAsAdmin { return $false } + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "AllUsers" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "administrator privileges" } + } + } + + Context "When Test-RunningAsAdmin throws exception" { + It "Should return false and log error" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "AllUsers" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Failed to validate administrator privileges" } + } + } + + Context "When modules are uninstalled successfully (object format)" { + It "Should uninstall all modules and return true" { + Mock Uninstall-PowershellModule { return $true } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git"; minimumVersion = "1.0.0"; scope = "CurrentUser" }, + @{ name = "PSReadLine"; minimumVersion = "2.2.6"; scope = "AllUsers" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } + } + } + + Context "When modules use default scope" { + It "Should use global scope setting" { + Mock Uninstall-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "CurrentUser" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + } + } + + Context "When module has no version specified" { + It "Should uninstall latest version" { + Mock Uninstall-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -match "latest version" } + } + } + + Context "When some modules fail to uninstall" { + It "Should continue and return true" { + Mock Uninstall-PowershellModule -MockWith { + param($ModuleName) + if ($ModuleName -eq "PSReadLine") { return $false } + return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" }, + @{ name = "PSReadLine" }, + @{ name = "PowerShellGet" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "PowerShellGet" } + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" } + } + } + + Context "When an exception occurs during uninstallation" { + It "Should catch exception, continue, and return true" { + Mock Uninstall-PowershellModule { + param($ModuleName) + if ($ModuleName -eq "ErrorModule") { throw "Uninstallation error" } + return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "ErrorModule" }, + @{ name = "GoodModule" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Error uninstalling module ErrorModule" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "ErrorModule" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "GoodModule" } + } + } + + Context "When DryRun is specified" { + It "Should pass WhatIf to Uninstall-PowershellModule" { + Mock Uninstall-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData -DryRun + $result | Should -Be $true + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $WhatIf -eq $true } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModules.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.ps1 similarity index 56% rename from DevSetup/Private/Providers/Powershell/Uninstall-PowershellModules.ps1 rename to DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.ps1 index 563910a..e52fa7b 100644 --- a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModules.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.ps1 @@ -21,7 +21,7 @@ .EXAMPLE $config = Read-ConfigurationFile -Path "environment.yaml" - Uninstall-PowershellModules -YamlData $config + Invoke-PowershellModulesUninstall -YamlData $config Uninstalls all PowerShell modules defined in the environment.yaml configuration. @@ -36,12 +36,12 @@ } } } - Uninstall-PowershellModules -YamlData $yamlData + Invoke-PowershellModulesUninstall -YamlData $yamlData Demonstrates uninstalling modules using a programmatically created configuration. .EXAMPLE - if (Uninstall-PowershellModules -YamlData $config) { + if (Invoke-PowershellModulesUninstall -YamlData $config) { Write-Host "All PowerShell modules processed successfully" } else { Write-Host "PowerShell module uninstallation encountered errors" @@ -76,82 +76,69 @@ Package Management, Batch Uninstallation, Configuration Processing, Module Management #> -Function Uninstall-PowershellModules { +Function Invoke-PowershellModulesUninstall { Param( [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] - [PSCustomObject]$YamlData + [PSCustomObject]$YamlData, + [Parameter(Mandatory=$false, Position=1)] + [switch]$DryRun ) + $modules = $YamlData.devsetup.dependencies.powershell.modules + + # Get global scope setting from YAML, default to CurrentUser + $globalScope = if ($YamlData.devsetup.dependencies.powershell.scope) { + $YamlData.devsetup.dependencies.powershell.scope + } else { + 'CurrentUser' + } + try { - # Check if PowerShell modules dependencies exist - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.powershell -or -not $YamlData.devsetup.dependencies.powershell.modules) { - Write-Warning "PowerShell modules not found in YAML configuration. Skipping uninstallation." - return $false - } - - $modules = $YamlData.devsetup.dependencies.powershell.modules - - # Get global scope setting from YAML, default to CurrentUser - $globalScope = if ($YamlData.devsetup.dependencies.powershell.scope) { - $YamlData.devsetup.dependencies.powershell.scope - } else { - 'CurrentUser' - } - # Check if running as administrator when global scope is AllUsers if ($globalScope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { - throw "PowerShell module uninstallation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." + Write-StatusMessage "PowerShell module uninstallation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - Write-StatusMessage "- Uninstalling PowerShell modules from configuration:" -ForegroundColor Cyan - - $moduleCount = 0 - - foreach ($module in $modules) { - if (-not $module) { continue } - - $moduleCount++ - - # Normalize module to object format - if ($module -is [string]) { - $moduleObj = @{ name = $module } - } else { - $moduleObj = $module - } - - # Validate module name - if ([string]::IsNullOrEmpty($moduleObj.name)) { - Write-Warning "Module entry #$moduleCount has no name specified, skipping" - continue - } - - # Determine scope for this module (module-specific overrides global) - $moduleScope = if ($moduleObj.scope) { $moduleObj.scope } else { $globalScope } - - # Set defaults and build parameters - $installParams = @{ - ModuleName = $moduleObj.name - } - - if ($moduleObj.minimumVersion) { - Write-StatusMessage "- Uninstalling PowerShell module: $($moduleObj.name) (version: $($moduleObj.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 - } else { - Write-StatusMessage "- Uninstalling PowerShell module: $($moduleObj.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 - } + Write-StatusMessage "- Uninstalling PowerShell modules from configuration:" -ForegroundColor Cyan + $moduleCount = 0 + + foreach ($module in $modules) { + # Determine scope for this module (module-specific overrides global) + $moduleScope = if ($module.scope) { $module.scope } else { $globalScope } + + # Set defaults and build parameters + $installParams = @{ + ModuleName = $module.name + WhatIf = $DryRun + } + + if ($module.minimumVersion) { + Write-StatusMessage "- Uninstalling PowerShell module: $($module.name) (version: $($module.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 + } else { + Write-StatusMessage "- Uninstalling PowerShell module: $($module.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 + } + + try { if ((Uninstall-PowerShellModule @installParams)) { Write-StatusMessage "[OK]" -ForegroundColor Green + $moduleCount++ } else { Write-StatusMessage "[FAILED]" -ForegroundColor Red } + } catch { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + Write-StatusMessage "Error uninstalling module $($module.name): $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error } - Write-StatusMessage "- PowerShell modules uninstallation completed! Processed $moduleCount modules." -ForegroundColor Green - Write-Host "" - return $true - } - catch { - Write-Error "Error uninstalling PowerShell modules: $_" - return $false } + Write-StatusMessage "- PowerShell modules uninstallation completed! Processed $moduleCount modules.`n" -ForegroundColor Green + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 index 37910d3..3498303 100644 --- a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 @@ -3,12 +3,16 @@ BeforeAll { . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\Get-PowershellModuleScopeMap.ps1 + + Mock Write-StatusMessage { } + if($PSVersionTable.PSVersion.Major -eq 5) { - $script:LocalModulePath = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\" - $script:AllUsersModulePath = "$env:ProgramFiles\WindowsPowerShell\Modules\" + $script:LocalModulePath = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules" + $script:AllUsersModulePath = "$env:ProgramFiles\WindowsPowerShell\Modules" Mock Get-EnvironmentVariable { Param( - [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] $Name ) switch ($Name) { @@ -19,11 +23,10 @@ BeforeAll { Mock Test-OperatingSystem { $true } } else { if($IsWindows) { - $script:LocalModulePath = "$env:USERPROFILE\Documents\PowerShell\Modules\" - $script:AllUsersModulePath = "$env:ProgramFiles\PowerShell\Modules\" + $script:LocalModulePath = "$env:USERPROFILE\Documents\PowerShell\Modules" + $script:AllUsersModulePath = "$env:ProgramFiles\PowerShell\Modules" Mock Get-EnvironmentVariable { Param( - [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] $Name ) switch ($Name) { @@ -35,11 +38,10 @@ BeforeAll { Mock Test-OperatingSystem { $true } } if($IsLinux) { - $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules/" - $script:AllUsersModulePath = "/usr/local/share/powershell/Modules/" + $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules" + $script:AllUsersModulePath = "/usr/local/share/powershell/Modules" Mock Get-EnvironmentVariable { Param( - [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] $Name ) switch ($Name) { @@ -51,11 +53,10 @@ BeforeAll { Mock Test-OperatingSystem { $false } } if($IsMacOS) { - $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules/" - $script:AllUsersModulePath = "/usr/local/share/powershell/Modules/" + $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules" + $script:AllUsersModulePath = "/usr/local/share/powershell/Modules" Mock Get-EnvironmentVariable { Param( - [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] $Name ) switch ($Name) { @@ -67,6 +68,12 @@ BeforeAll { Mock Test-OperatingSystem { $false } } } + + # Mock Get-PowershellModuleScopeMap to return appropriate paths + Mock Get-PowershellModuleScopeMap { @( + @{ Path = $script:LocalModulePath; Scope = "CurrentUser" }, + @{ Path = $script:AllUsersModulePath; Scope = "AllUsers" } + ) } } Describe "Test-PowershellModuleInstalled" { @@ -85,7 +92,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "posh-git" Version = "1.0.0" - Path = "$($script:LocalModulePath)posh-git" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)posh-git\posh-git.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "posh-git" @@ -100,7 +107,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "PSReadLine" Version = "2.2.6" - Path = "$($script:LocalModulePath)PSReadLine" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)PSReadLine\PSReadLine.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "PSReadLine" -Version "2.2.6" @@ -115,7 +122,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "PSReadLine" Version = "2.2.5" - Path = "$($script:LocalModulePath)PSReadLine" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)PSReadLine\PSReadLine.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "PSReadLine" -Version "2.2.6" @@ -130,7 +137,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "PowerShellGet" Version = "2.2.5" - Path = "$($script:AllUsersModulePath)PowerShellGet" + Path = "$($script:AllUsersModulePath)$([System.IO.Path]::DirectorySeparatorChar)PowerShellGet\PowerShellGet.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "PowerShellGet" -Scope "AllUsers" @@ -145,7 +152,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "Az" Version = "9.0.1" - Path = "$($script:LocalModulePath)Az" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)Az\Az.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "Az" -Scope "CurrentUser" @@ -161,4 +168,52 @@ Describe "Test-PowershellModuleInstalled" { $result | Should -BeExactly ([InstalledState]::NotInstalled) } } + + Context "When Get-PowershellModuleScopeMap throws an exception" { + It "Should return NotInstalled and log error" { + Mock Get-PowershellModuleScopeMap { throw "Scope map error" } + $result = Test-PowershellModuleInstalled -ModuleName "Az" + $result | Should -BeExactly ([InstalledState]::NotInstalled) + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to get PowerShell module scope map" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-PowershellModuleScopeMap returns empty" { + It "Should return NotInstalled and log warning" { + Mock Get-PowershellModuleScopeMap { @() } + $result = Test-PowershellModuleInstalled -ModuleName "Az" + $result | Should -BeExactly ([InstalledState]::NotInstalled) + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "No PowerShell module install paths found" -and $Verbosity -eq "Warning" } + } + } + + Context "When module is installed in wrong scope" { + It "Should return Installed + MinimumVersionMet + RequiredVersionMet (without GlobalVersionMet)" { + Mock Get-Module { + [PSCustomObject]@{ + Name = "TestModule" + Version = "1.0.0" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)TestModule\TestModule.psd1" + } + } + $result = Test-PowershellModuleInstalled -ModuleName "TestModule" -Scope "AllUsers" + $expected = [InstalledState]::Installed + [InstalledState]::MinimumVersionMet + [InstalledState]::RequiredVersionMet + $result | Should -BeExactly $expected + } + } + + Context "When module is installed with version and scope both matching" { + It "Should return full Pass state" { + Mock Get-Module { + [PSCustomObject]@{ + Name = "FullTest" + Version = "3.1.4" + Path = "$($script:AllUsersModulePath)$([System.IO.Path]::DirectorySeparatorChar)FullTest\FullTest.psd1" + } + } + $result = Test-PowershellModuleInstalled -ModuleName "FullTest" -Version "3.1.4" -Scope "AllUsers" + $expected = [InstalledState]::Pass + $result | Should -BeExactly $expected + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 index ab8f0d7..d6a140f 100644 --- a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 +++ b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 @@ -90,41 +90,25 @@ Function Test-PowershellModuleInstalled { [string]$Scope ) - # CurrentUser ps5.1 - # $env:USERPROFILE\Documents\WindowsPowerShell\Modules - # CurrentUser ps7 - # $env:USERPROFILE\Documents\PowerShell\Modules - # CurrentUser ps7 (linux/macos) - # $env:HOME/.local/share/powershell/Modules - - # AllUsers ps5.1 - # $env:ProgramFiles\WindowsPowerShell\Modules - # AllUsers ps7 - # $env:ProgramFiles\PowerShell\Modules - # AllUsers ps7 (linux/macos) - # $env:HOME/.local/share/powershell/Modules - if((Test-OperatingSystem -Windows)) { - $SearchPath = (Get-EnvironmentVariable USERPROFILE) - } else { - $SearchPath = (Get-EnvironmentVariable HOME) + try { + $InstallPaths = Get-PowershellModuleScopeMap + } catch { + Write-StatusMessage "Failed to get PowerShell module scope map: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return [InstalledState]::NotInstalled } - $InstallPaths = @( - (Get-EnvironmentVariable PSModulePath) -split ([System.IO.Path]::PathSeparator) | ForEach-Object { - if($_ -match [regex]::Escape("$SearchPath")) { - @{ Path = $_; Scope = "CurrentUser" } - } else { - @{ Path = $_; Scope = "AllUsers" } - } - } - ) + if(-not $InstallPaths -or $InstallPaths.Count -eq 0) { + Write-StatusMessage "No PowerShell module install paths found." -Verbosity Warning + return [InstalledState]::NotInstalled + } [InstalledState]$installedState = [InstalledState]::NotInstalled try { $module = Get-Module -Name $ModuleName -ListAvailable -ErrorAction Stop | - Sort-Object Version -Descending | - Select-Object -First 1 + Sort-Object Version -Descending | + Select-Object -First 1 if ($module) { $installedState = [InstalledState]::Installed diff --git a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 index 1ebb2af..4266cc0 100644 --- a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 @@ -2,108 +2,351 @@ BeforeAll { . $PSScriptRoot\Uninstall-PowershellModule.ps1 . $PSScriptRoot\Test-PowershellModuleInstalled.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 - Mock Test-RunningAsAdmin { return $true } - Mock Write-Warning { } - Mock Write-Error { } - Mock Write-Debug { } + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 } Describe "Uninstall-PowershellModule" { + BeforeEach { + Mock Test-RunningAsAdmin { return $true } + Mock Write-StatusMessage { } + Mock Remove-Module { } + Mock Uninstall-Module { } + } + Context "When module is not installed" { - It "Should return true and warn" { + It "Should return true and log warning" { Mock Test-PowershellModuleInstalled { return [InstalledState]::NotInstalled } - $result = Uninstall-PowershellModule -ModuleName "notfound" + $result = Uninstall-PowershellModule -ModuleName "NonExistentModule" $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "NonExistentModule.*is not installed" + } -Times 1 } } - Context "When module is installed for AllUsers but not running as admin" { - It "Should return false and warn" { + Context "When initial check throws exception" { + It "Should return false and log error" { + Mock Test-PowershellModuleInstalled { throw "Check failed" } + $result = Uninstall-PowershellModule -ModuleName "ErrorModule" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error checking installation status" + } -Times 1 + } + } + + Context "When scope check throws exception" { + It "Should return false and log error" { $callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param($ModuleName, $Scope) - $callCount++ - if ($callCount -eq 1) { return [InstalledState]::Installed } - if ($callCount -eq 2) { return [InstalledState]::Pass } - return [InstalledState]::NotInstalled + Mock Test-PowershellModuleInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + throw "Scope check failed" } + $result = Uninstall-PowershellModule -ModuleName "ScopeError" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error checking installation scope" + } -Times 1 + } + } + + Context "When AllUsers module but not admin" { + It "Should return false and warn" { + # Use parameter-based mocking instead of call counting + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } -ParameterFilter { -not $PSBoundParameters.ContainsKey('Scope') } + Mock Test-PowershellModuleInstalled { return [InstalledState]::Pass } -ParameterFilter { $Scope -eq 'AllUsers' } Mock Test-RunningAsAdmin { return $false } - $result = Uninstall-PowershellModule -ModuleName "Az" + $result = Uninstall-PowershellModule -ModuleName "AdminModule" $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "installed for AllUsers but current session is not elevated" + } -Times 1 } } - Context "When module is installed and uninstall succeeds" { - It "Should remove and uninstall the module, returning true" { + Context "When uninstall succeeds" { + It "Should return true" { $script:callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param($ModuleName, $Scope) + Mock Test-PowershellModuleInstalled { $script:callCount++ if ($script:callCount -eq 1) { return [InstalledState]::Installed } if ($script:callCount -eq 2) { return [InstalledState]::Installed } if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } return [InstalledState]::NotInstalled } - $script:removeCalled = $false - $script:uninstallCalled = $false - Mock Remove-Module -MockWith { - param([string]$Name, [switch]$Force, [string]$ErrorAction) - $script:removeCalled = $true - } - Mock Uninstall-Module -MockWith { - param([string]$Name, [switch]$Force, [string]$ErrorAction) - $script:uninstallCalled = $true - } - $result = Uninstall-PowershellModule -ModuleName "posh-git" - $removeCalled | Should -Be $true - $uninstallCalled | Should -Be $true + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false $result | Should -Be $true + Assert-MockCalled Remove-Module -Times 1 + Assert-MockCalled Uninstall-Module -Times 1 } } - Context "When uninstall fails with exception" { - It "Should return false and write error" { + Context "When Remove-Module fails" { + It "Should continue and succeed" { $script:callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param($ModuleName, $Scope) + Mock Test-PowershellModuleInstalled { $script:callCount++ if ($script:callCount -eq 1) { return [InstalledState]::Installed } if ($script:callCount -eq 2) { return [InstalledState]::Installed } + if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } return [InstalledState]::NotInstalled } - Mock Remove-Module -MockWith { - param([string]$Name, [switch]$Force, [string]$ErrorAction) + Mock Remove-Module { throw "Remove failed" } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Failed to remove module.*from current session" + } -Times 1 + Assert-MockCalled Uninstall-Module -Times 1 + } + } + + Context "When Uninstall-Module fails" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { return [InstalledState]::Installed } + return [InstalledState]::NotInstalled } - Mock Uninstall-Module -MockWith { - param([string]$Name, [switch]$Force, [string]$ErrorAction) - throw "Uninstall failed" + Mock Uninstall-Module { throw "Uninstall failed" } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error during Uninstall-Module" + } -Times 1 + } + } + + Context "When final verification fails" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { return [InstalledState]::Installed } + if ($script:callCount -eq 3) { throw "Verify failed" } + return [InstalledState]::NotInstalled } - $result = Uninstall-PowershellModule -ModuleName "PSReadLine" + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error verifying uninstallation" + } -Times 1 } } - Context "When module is installed but still present after uninstall" { + Context "When module still installed after uninstall" { It "Should return false" { $script:callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param($ModuleName, $Scope) + Mock Test-PowershellModuleInstalled { $script:callCount++ if ($script:callCount -eq 1) { return [InstalledState]::Installed } if ($script:callCount -eq 2) { return [InstalledState]::Installed } if ($script:callCount -eq 3) { return [InstalledState]::Installed } + return [InstalledState]::Installed + } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false + $result | Should -Be $false + } + } + + Context "When using WhatIf" { + It "Should return true and not uninstall" { + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -WhatIf + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "was cancelled by user" + } -Times 1 + Assert-MockCalled Remove-Module -Times 0 + Assert-MockCalled Uninstall-Module -Times 0 + } + } + + Context "When scope installation check throws exception" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { throw "Scope check failed" } + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "ScopeErrorModule" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error checking installation scope.*ScopeErrorModule.*Scope check failed" + } -Times 1 + } + } + + Context "When module is installed for AllUsers but not running as admin" { + It "Should return false and warn about privileges" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { return [InstalledState]::Pass } # Has Pass flag for AllUsers return [InstalledState]::NotInstalled } - Mock Remove-Module -MockWith { - param([string]$Name, [switch]$Force, [string]$ErrorAction) + Mock Test-RunningAsAdmin { return $false } + $result = Uninstall-PowershellModule -ModuleName "AdminRequiredModule" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "AdminRequiredModule.*installed for AllUsers but current session is not elevated" + } -Times 1 + } + } + + Context "When module uninstall succeeds" { + It "Should return true and call Remove-Module and Uninstall-Module" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check (CurrentUser) + if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } # Final verification + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "SuccessModule" -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Debug" -and $Message -match "Uninstalling PowerShell module \'SuccessModule\'..." + } -Times 1 + Assert-MockCalled Remove-Module -ParameterFilter { + $Name -eq "SuccessModule" -and $Force -eq $true + } -Times 1 + Assert-MockCalled Uninstall-Module -ParameterFilter { + $Name -eq "SuccessModule" -and $Force -eq $true + } -Times 1 + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Debug" -and $Message -match "PowerShell module \'SuccessModule\' uninstalled successfully." + } -Times 1 + } + } + + Context "When Remove-Module throws exception" { + It "Should log warning and continue with Uninstall-Module" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } # Final verification + return [InstalledState]::NotInstalled } - Mock Uninstall-Module -MockWith { - param([string]$Name, [switch]$Force, [string]$ErrorAction) + Mock Remove-Module { throw "Remove-Module failed" } + $result = Uninstall-PowershellModule -ModuleName "RemoveErrorModule" -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Failed to remove module 'RemoveErrorModule' from current session.*Remove-Module failed" + } -Times 1 + Assert-MockCalled Uninstall-Module -ParameterFilter { + $Name -eq "RemoveErrorModule" -and $Force -eq $true + } -Times 1 + } + } + + Context "When Uninstall-Module throws exception" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + return [InstalledState]::NotInstalled } - $result = Uninstall-PowershellModule -ModuleName "PowerShellGet" + Mock Uninstall-Module { throw "Uninstall-Module failed" } + $result = Uninstall-PowershellModule -ModuleName "UninstallErrorModule" -Confirm:$false $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error during Uninstall-Module for 'UninstallErrorModule'.*Uninstall-Module failed" + } -Times 1 + } + } + + Context "When final verification throws exception" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + if ($script:callCount -eq 3) { throw "Final verification failed" } # Final verification throws + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "VerifyErrorModule" -Confirm:$false + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error verifying uninstallation.*VerifyErrorModule.*Final verification failed" + } -Times 1 + } + } + + Context "When module still shows as installed after uninstall" { + It "Should return false" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + if ($script:callCount -eq 3) { return [InstalledState]::Installed } # Final verification - still installed + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "PersistentModule" -Confirm:$false + $result | Should -Be $false + Assert-MockCalled Uninstall-Module -ParameterFilter { + $Name -eq "PersistentModule" + } -Times 1 + } + } + + Context "When using WhatIf parameter" { + It "Should return true and not actually uninstall" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "WhatIfModule" -WhatIf + $result | Should -Be $true # ShouldProcess returns false for WhatIf, so else branch returns true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Uninstallation of PowerShell module 'WhatIfModule' was cancelled by user" + } -Times 1 + Assert-MockCalled Remove-Module -Times 0 + Assert-MockCalled Uninstall-Module -Times 0 + } + } + + Context "When user cancels ShouldProcess confirmation" { + It "Should return true and log cancellation message" { + # This test demonstrates the new behavior where cancellation returns true + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } -ParameterFilter { -not $PSBoundParameters.ContainsKey('Scope') } + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } -ParameterFilter { $Scope -eq 'AllUsers' } + + # Test using WhatIf to simulate ShouldProcess returning false + $result = Uninstall-PowershellModule -ModuleName "CancelledModule" -WhatIf + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Uninstallation of PowerShell module 'CancelledModule' was cancelled by user" + } -Times 1 + Assert-MockCalled Remove-Module -Times 0 + Assert-MockCalled Uninstall-Module -Times 0 } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 index 44e563d..8df9066 100644 --- a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 +++ b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 @@ -64,33 +64,63 @@ #> Function Uninstall-PowershellModule { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [String] $ModuleName ) - $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName + try { + $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName + } catch { + Write-StatusMessage "Error checking installation status of PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } if ($installedState -eq [InstalledState]::NotInstalled) { - Write-Warning "PowerShell module '$ModuleName' is not installed. No action taken." + Write-StatusMessage "PowerShell module '$ModuleName' is not installed. No action taken." -Verbosity Warning return $true } - $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName -Scope 'AllUsers' + try { + $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName -Scope 'AllUsers' + } catch { + Write-StatusMessage "Error checking installation scope of PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } if ($installedState.HasFlag([InstalledState]::Pass) -and (-not (Test-RunningAsAdmin))) { - Write-Warning "PowerShell module '$ModuleName' is installed for AllUsers but current session is not elevated. Cannot uninstall." + Write-StatusMessage "PowerShell module '$ModuleName' is installed for AllUsers but current session is not elevated. Cannot uninstall." -Verbosity Warning return $false } + Write-StatusMessage "Uninstalling PowerShell module '$ModuleName'..." -Verbosity Debug + if ($PSCmdlet.ShouldProcess($ModuleName, "Uninstall-Module")) { + try { + Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue + } catch { + Write-StatusMessage "Warning: Failed to remove module '$ModuleName' from current session: $_" -Verbosity Warning + } + try { + Uninstall-Module -Name $ModuleName -Force -ErrorAction Stop + } catch { + Write-StatusMessage "Error during Uninstall-Module for '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + } else { + Write-StatusMessage "Uninstallation of PowerShell module '$ModuleName' was cancelled by user." -Verbosity Warning + return $true + } + + Write-StatusMessage "PowerShell module '$ModuleName' uninstalled successfully." -Verbosity Debug + try { - Write-Debug "Uninstalling PowerShell module '$ModuleName'..." - Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue - Uninstall-Module -Name $ModuleName -Force -ErrorAction Stop - Write-Debug "PowerShell module '$ModuleName' uninstalled successfully." $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName - return ($installedState -eq [InstalledState]::NotInstalled) } catch { - Write-Error "Failed to uninstall PowerShell module '$ModuleName': $_" + Write-StatusMessage "Error verifying uninstallation of PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } + return ($installedState -eq [InstalledState]::NotInstalled) } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModules.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModules.Tests.ps1 deleted file mode 100644 index 03f53c7..0000000 --- a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModules.Tests.ps1 +++ /dev/null @@ -1,170 +0,0 @@ -BeforeAll { - . (Join-Path $PSScriptRoot "Uninstall-PowershellModules.ps1") - . (Join-Path $PSScriptRoot "Uninstall-PowershellModule.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") - Mock Write-StatusMessage { } - Mock Write-Warning { } - Mock Write-Error { } - Mock Test-RunningAsAdmin { return $true } - Mock Write-Host { } -} - -Describe "Uninstall-PowershellModules" { - - Context "When YAML configuration is missing PowerShell modules" { - It "Should return false and warn" { - $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ } } } } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When YAML configuration is missing dependencies" { - It "Should return false and warn" { - $yamlData = @{ devsetup = @{ } } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When AllUsers scope is specified but not running as admin" { - It "Should return false" { - Mock Test-RunningAsAdmin { return $false } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - scope = "AllUsers" - modules = @("posh-git") - } - } - } - } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When modules are uninstalled successfully (string format)" { - It "Should uninstall all modules and return true" { - $script:uninstallCalls = @() - Mock Uninstall-PowershellModule -MockWith { - param($ModuleName) - $script:uninstallCalls += $ModuleName - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @("posh-git", "PSReadLine") - } - } - } - } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls | Should -Contain "PSReadLine" - } - } - - Context "When modules are uninstalled successfully (object format)" { - It "Should uninstall all modules and return true" { - $script:uninstallCalls = @() - Mock Uninstall-PowershellModule -MockWith { - param($ModuleName) - $script:uninstallCalls += $ModuleName - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @( - @{ name = "posh-git"; minimumVersion = "1.0.0"; scope = "CurrentUser" }, - @{ name = "PSReadLine"; minimumVersion = "2.2.6"; scope = "AllUsers" } - ) - } - } - } - } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls | Should -Contain "PSReadLine" - } - } - - Context "When some modules fail to uninstall" { - It "Should continue and return true" { - $script:uninstallCalls = @() - Mock Uninstall-PowershellModule -MockWith { - param($ModuleName) - $script:uninstallCalls += $ModuleName - if ($ModuleName -eq "PSReadLine") { return $false } - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @("posh-git", "PSReadLine", "PowerShellGet") - } - } - } - } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls | Should -Contain "PSReadLine" - $uninstallCalls | Should -Contain "PowerShellGet" - } - } - - Context "When module entry is empty or missing name" { - It "Should skip invalid entries and return true" { - $script:uninstallCalls = @() - Mock Uninstall-PowershellModule -MockWith { - param($ModuleName) - $script:uninstallCalls += $ModuleName - return $true - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @( - $null, - @{ minimumVersion = "1.0.0" }, - "posh-git" - ) - } - } - } - } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls.Count | Should -Be 1 - } - } - - Context "When an exception occurs during uninstallation" { - It "Should catch and return false" { - Mock Uninstall-PowershellModule { throw "Unexpected error" } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - powershell = @{ - modules = @("posh-git") - } - } - } - } - $result = Uninstall-PowershellModules -YamlData $yamlData - $result | Should -Be $false - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Export-InstalledScoopPackages.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Export-InstalledScoopPackages.Tests.ps1 deleted file mode 100644 index c45ee0b..0000000 --- a/DevSetup/Private/Providers/Scoop/Export-InstalledScoopPackages.Tests.ps1 +++ /dev/null @@ -1,107 +0,0 @@ -BeforeAll { - function ConvertTo-Yaml { } - . $PSScriptRoot\Export-InstalledScoopPackages.ps1 - . $PSScriptRoot\Test-ScoopInstalled.ps1 - . $PSScriptRoot\Find-Scoop.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Read-ConfigurationFile.ps1 - Mock Test-ScoopInstalled { $true } - Mock Find-Scoop { "scoop" } - Mock Invoke-Expression { '{"buckets":[{"Name":"extras","Source":"https://github.com/ScoopInstaller/Extras"}],"apps":[{"Name":"git","Version":"2.40.0","Source":"extras","Info":"Global install"}]}' } - Mock Read-ConfigurationFile { @{ devsetup = @{ dependencies = @{ scoop = @{ packages = @(); buckets = @() } } } } } - Mock ConvertTo-Yaml { param($obj) "yaml-output" } - Mock ConvertTo-Json { param($obj) "json-output" } - Mock Out-File { $true } - Mock Write-Host { } - Mock Write-Warning { } - Mock Write-Error { } - Mock Write-Debug { } - Mock Write-Verbose { } -} - -Describe "Export-InstalledScoopPackages" { - - Context "When Scoop is not installed" { - It "Should warn and return false" { - Mock Test-ScoopInstalled { $false } - $result = Export-InstalledScoopPackages -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "Scoop is not installed" } - } - } - - Context "When Scoop command is not found" { - It "Should warn and return false" { - Mock Find-Scoop { $null } - $result = Export-InstalledScoopPackages -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "Failed to find Scoop command" } - } - } - - Context "When no Scoop packages are found" { - It "Should warn and return true" { - Mock Invoke-Expression { $null } - $result = Export-InstalledScoopPackages -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "No Scoop packages found" } - } - } - - Context "When Scoop export JSON is invalid" { - It "Should warn and show raw output" { - Mock Invoke-Expression { "not-json" } - Mock ConvertFrom-Json { throw "JSON error" } - $result = Export-InstalledScoopPackages -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "Failed to parse scoop export JSON" } - } - } - - Context "When buckets and packages are found" { - It "Should add buckets and packages to YAML data" { - $result = Export-InstalledScoopPackages -Config "test.yaml" - $result | Should -BeTrue - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Adding bucket: extras" } - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Adding package: git" } - } - } - - Context "When DryRun is used" { - It "Should display YAML output and not write to file" { - $result = Export-InstalledScoopPackages -Config "test.yaml" -DryRun - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Yaml -Scope It - Assert-MockCalled Out-File -Times 0 -Scope It - Assert-MockCalled Write-Host -Scope It -ParameterFilter { $Object -match "Dry Run" } - } - } - - Context "When OutFile is specified" { - It "Should write YAML output to the specified file" { - $result = Export-InstalledScoopPackages -Config "test.yaml" -OutFile "out.yaml" - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Yaml -Scope It - Assert-MockCalled Out-File -Scope It -ParameterFilter { $FilePath -eq "out.yaml" } - } - } - - Context "When YAML conversion fails" { - It "Should fallback to JSON output" { - Mock ConvertTo-Yaml { throw "YAML error" } - $result = Export-InstalledScoopPackages -Config "test.yaml" -DryRun - $result | Should -BeTrue - Assert-MockCalled ConvertTo-Json -Scope It - Assert-MockCalled Write-Warning -Scope It -ParameterFilter { $Message -match "Could not convert to YAML format" } - } - } - - Context "When Out-File fails" { - It "Should write error and return false" { - Mock Out-File { throw "File error" } - $result = Export-InstalledScoopPackages -Config "test.yaml" - $result | Should -BeFalse - Assert-MockCalled Write-Error -Scope It -ParameterFilter { $Message -match "Failed to save configuration" } - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Export-InstalledScoopPackages.ps1 b/DevSetup/Private/Providers/Scoop/Export-InstalledScoopPackages.ps1 deleted file mode 100644 index 9e97e9a..0000000 --- a/DevSetup/Private/Providers/Scoop/Export-InstalledScoopPackages.ps1 +++ /dev/null @@ -1,412 +0,0 @@ -๏ปฟ<# -.SYNOPSIS - Exports installed Scoop packages and buckets to a YAML configuration file. - -.DESCRIPTION - This function scans the system for installed Scoop packages and buckets, then exports them to a YAML - configuration file in DevSetup format. It uses 'scoop export' to retrieve comprehensive package information - including versions, buckets, and global installation status. The function can update existing configuration - files by merging new packages with existing ones, or create new configurations from scratch. - -.PARAMETER Config - The path to the YAML configuration file to read from and write to. - This parameter is mandatory and specifies both the input and output file unless OutFile is specified. - -.PARAMETER OutFile - The path to save the updated YAML configuration. - Optional parameter that allows saving to a different file than the input Config file. - -.PARAMETER DryRun - Switch parameter that prevents writing to files and displays the resulting configuration to the console. - Useful for previewing changes before committing them to a file. - -.OUTPUTS - [System.Boolean] - Returns $true if the export completes successfully or if Scoop is not installed (skipped). - Returns $false if there are errors during the export process. - -.EXAMPLE - Export-InstalledScoopPackages -Config "environment.yaml" - - Exports installed Scoop packages to the existing environment.yaml configuration file. - -.EXAMPLE - Export-InstalledScoopPackages -Config "current.yaml" -OutFile "backup.yaml" - - Reads from current.yaml and saves the updated configuration with installed packages to backup.yaml. - -.EXAMPLE - Export-InstalledScoopPackages -Config "dev-env.yaml" -DryRun - - Shows what the configuration would look like without actually saving to file. - -.NOTES - - Requires Scoop to be installed on the system (gracefully skips if not found) - - Uses 'scoop export' command to retrieve package and bucket information in JSON format - - Handles both local and global package installations using Info field detection - - Automatically skips the 'main' bucket as it's installed by default with Scoop - - Merges with existing YAML configuration, preserving other sections and structure - - Supports both simple string format and complex object format for packages and buckets - - Updates existing packages/buckets when versions or sources have changed - - Tracks global installation status and bucket information for each package - - Provides detailed console output with color-coded status messages for all operations - - Creates the devsetup.dependencies.scoop structure if it doesn't exist - - Processes buckets before packages to ensure proper dependency order - - Converts string entries to hashtable format when additional properties are needed - - Preserves existing package properties while updating changed values - - Includes comprehensive error handling for JSON parsing and file operations - - Returns $true even when no packages are found (successful empty result) - -.LINK - -.COMPONENT - DevSetup.Providers.Scoop - -.FUNCTIONALITY - Configuration Export, Package Discovery, YAML Generation -#> - -Function Export-InstalledScoopPackages { - Param( - [Parameter(Mandatory = $true)] - [string]$Config, - [string]$OutFile, - [switch]$DryRun - ) - - try { - # Check if Scoop is installed - if(-Not (Test-ScoopInstalled)) { - Write-Warning "Scoop is not installed. Cannot check for components." - return $false - } - - $scoopCommand = Find-Scoop - if (-not $scoopCommand) { - Write-Warning "Failed to find Scoop command. Cannot check for components." - return $false - } - - # Get list of installed Scoop packages - Write-Host "- Getting list of installed Scoop packages..." -ForegroundColor Gray - - # Get all packages (both local and global) using scoop export - $scoopListLocal = "" - - try { - # Use scoop export - it returns JSON with both local and global packages - $command = "& '$scoopCommand' export" - $scoopListLocal = Invoke-Expression $command 6>$null - if (-not $scoopListLocal) { - Write-Warning "No Scoop packages found or scoop export command failed." - return $true - } - } catch { - Write-Verbose "Could not get Scoop packages: $_" - } - - # TODO: - # scoop kinda sucks, they do so many weird things, for instance scoop install helm works fine and produces what you'd expect - # scoop install main/helm, totally kills what the source was in scoop list and provides a path to a json file - # scoop install main/helm@3.17.4 is even worse, it provides for the source - # in order to make sure we dont have problems with exported configurations we need to "look up" each package and see what bucket - # it actually belongs in while exporting so when someone imports it back in later, it provides a valid bucket to install from - # scoop search '^helm$' - - $scoopPackages = @() - $scoopBuckets = @() - - # Parse packages from scoop export JSON - if ($scoopListLocal) { - try { - # Convert JSON output to PowerShell object - $exportData = $scoopListLocal | ConvertFrom-Json - - # Parse buckets from the JSON structure - if ($exportData.buckets -and $exportData.buckets.Count -gt 0) { - foreach ($bucket in $exportData.buckets) { - # Skip the 'main' bucket as it's automatically installed with Scoop - if ($bucket.Name -eq "main") { - Write-Debug "Skipping 'main' bucket (automatically installed with Scoop)" - continue - } - - $bucketInfo = @{ - name = $bucket.Name - source = $bucket.Source - } - $scoopBuckets += $bucketInfo - Write-Debug "Found bucket: $($bucket.Name) (source: $($bucket.Source))" - } - } - - # Parse apps from the JSON structure - if ($exportData.apps -and $exportData.apps.Count -gt 0) { - foreach ($app in $exportData.apps) { - $packageName = $app.Name - $version = $app.Version - $bucket = $app.Source - - # Determine if this is a global install based on the Info field - $isGlobal = $app.Info -eq "Global install" - - Write-Debug "Found package from JSON export: $packageName (version: $version, bucket: $bucket, global: $isGlobal)" - $packageInfo = @{ - name = $packageName - version = $version - global = $isGlobal - } - - # Always include bucket information for clarity - if ($bucket) { - $packageInfo.bucket = $bucket - } - - $scoopPackages += $packageInfo - } - } else { - Write-Verbose "No apps found in scoop export JSON" - } - } catch { - Write-Warning "Failed to parse scoop export JSON: $_" - Write-Verbose "Raw export output: $scoopListLocal" - } - } - - if ($scoopPackages.Count -eq 0) { - Write-Warning "No Scoop packages found." - return $true - } - - Write-Debug "Found $($scoopPackages.Count) Scoop packages and $($scoopBuckets.Count) buckets" - - # Read existing YAML configuration - $YamlData = Read-ConfigurationFile -Config $Config - - # Ensure scoopPackages and scoopBuckets sections exist - if (-not $YamlData.devsetup) { $YamlData.devsetup = @{} } - if (-not $YamlData.devsetup.dependencies) { $YamlData.devsetup.dependencies = @{} } - if (-not $YamlData.devsetup.dependencies.scoop) { $YamlData.devsetup.dependencies.scoop = @{} } - if (-not $YamlData.devsetup.dependencies.scoop.packages) { $YamlData.devsetup.dependencies.scoop.packages = @() } - if (-not $YamlData.devsetup.dependencies.scoop.buckets) { $YamlData.devsetup.dependencies.scoop.buckets = @() } - - # Add buckets to YAML data first (packages may depend on these buckets) - foreach ($bucket in $scoopBuckets) { - # Check if bucket already exists - $existingBucket = $YamlData.devsetup.dependencies.scoop.buckets | Where-Object { - ($_ -is [string] -and $_ -eq $bucket.name) -or - ($_ -is [hashtable] -and $_.name -eq $bucket.name) - } - - if (-not $existingBucket) { - Write-Host " - Adding bucket: $($bucket.name) ($($bucket.source))" -ForegroundColor Gray - - # Create bucket object - $bucketObj = @{ - name = $bucket.name - source = $bucket.source - } - - $YamlData.devsetup.dependencies.scoop.buckets += $bucketObj - } else { - # Bucket exists, check if source has changed - $existingSource = $null - - if ($existingBucket -is [hashtable]) { - $existingSource = $existingBucket.source - } - - if ($existingSource -and $existingSource -ne $bucket.source) { - Write-Host " - Updating bucket: $($bucket.name) ($existingSource -> $($bucket.source))" -ForegroundColor Cyan - - # Find index and update - $index = $YamlData.devsetup.dependencies.scoop.buckets.IndexOf($existingBucket) - - if ($existingBucket -is [string]) { - # Convert string to hashtable with source - $bucketObj = @{ - name = $bucket.name - source = $bucket.source - } - - $YamlData.devsetup.dependencies.scoop.buckets[$index] = $bucketObj - } else { - # Update existing hashtable - $YamlData.devsetup.dependencies.scoop.buckets[$index].source = $bucket.source - } - } elseif (-not $existingSource) { - Write-Host " - Updating bucket: $($bucket.name)" -ForegroundColor Yellow - - # Find index and add source - $index = $YamlData.devsetup.dependencies.scoop.buckets.IndexOf($existingBucket) - - if ($existingBucket -is [string]) { - # Convert string to hashtable with source - $bucketObj = @{ - name = $bucket.name - source = $bucket.source - } - - $YamlData.devsetup.dependencies.scoop.buckets[$index] = $bucketObj - } else { - # Add source to existing hashtable - $YamlData.devsetup.dependencies.scoop.buckets[$index].source = $bucket.source - } - } else { - Write-Host " - Skipping bucket (No Change): $($bucket.name) ($($bucket.source))" -ForegroundColor Gray - } - } - } - - # Add packages to YAML data - foreach ($package in $scoopPackages) { - # Check if package already exists - $existingPackage = $YamlData.devsetup.dependencies.scoop.packages | Where-Object { - ($_ -is [string] -and $_ -eq $package.name) -or - ($_ -is [hashtable] -and $_.name -eq $package.name) - } - - if (-not $existingPackage) { - Write-Host " - Adding package: $($package.name) ($($package.version))" -ForegroundColor Gray - - # Create package object with all relevant properties - $packageObj = @{ - name = $package.name - version = $package.version - } - - if ($package.bucket) { - $packageObj.bucket = $package.bucket - } - - if ($package.global) { - $packageObj.global = $package.global - } - - $YamlData.devsetup.dependencies.scoop.packages += $packageObj - } else { - # Package exists, check if version has changed - $existingVersion = $null - $existingGlobal = $false - $existingBucket = $null - - if ($existingPackage -is [hashtable]) { - $existingVersion = $existingPackage.version - $existingGlobal = $existingPackage.global - $existingBucket = $existingPackage.bucket - } - - if ($existingVersion -and $existingVersion -ne $package.version) { - Write-Host " - Updating package: $($package.name) ($existingVersion -> $($package.version))" -ForegroundColor Cyan - - # Find index and update - $index = $YamlData.devsetup.dependencies.scoop.packages.IndexOf($existingPackage) - - # Preserve existing package structure but update version - if ($existingPackage -is [string]) { - # Convert string to hashtable with version and other properties - $packageObj = @{ - name = $package.name - version = $package.version - } - - if ($package.bucket) { - $packageObj.bucket = $package.bucket - } - - if ($package.global) { - $packageObj.global = $package.global - } - - $YamlData.devsetup.dependencies.scoop.packages[$index] = $packageObj - } else { - # Update existing hashtable - $YamlData.devsetup.dependencies.scoop.packages[$index].version = $package.version - - # Update bucket if changed - if ($package.bucket -and (-not $existingBucket -or $existingBucket -ne $package.bucket)) { - $YamlData.devsetup.dependencies.scoop.packages[$index].bucket = $package.bucket - } - - # Update global flag if changed - if ($package.global -ne $existingGlobal) { - $YamlData.devsetup.dependencies.scoop.packages[$index].global = $package.global - } - } - } elseif (-not $existingVersion) { - Write-Host " - Updating package: $($package.name)" -ForegroundColor Yellow - - # Find index and add version and other properties - $index = $YamlData.devsetup.dependencies.scoop.packages.IndexOf($existingPackage) - - if ($existingPackage -is [string]) { - # Convert string to hashtable with version and properties - $packageObj = @{ - name = $package.name - version = $package.version - } - - if ($package.bucket) { - $packageObj.bucket = $package.bucket - } - - if ($package.global) { - $packageObj.global = $package.global - } - - $YamlData.devsetup.dependencies.scoop.packages[$index] = $packageObj - } else { - # Add version and other properties to existing hashtable - $YamlData.devsetup.dependencies.scoop.packages[$index].version = $package.version - - if ($package.bucket -and -not $existingBucket) { - $YamlData.devsetup.dependencies.scoop.packages[$index].bucket = $package.bucket - } - - if ($package.global -and -not $existingGlobal) { - $YamlData.devsetup.dependencies.scoop.packages[$index].global = $package.global - } - } - } else { - Write-Host " - Skipping package (No Change): $($package.name) ($($package.version))" -ForegroundColor Gray - } - } - } - - # Convert to YAML - try { - $yamlOutput = $YamlData | ConvertTo-Yaml - } - catch { - Write-Warning "Could not convert to YAML format. Showing PowerShell object instead:" - $yamlOutput = $YamlData | ConvertTo-Json -Depth 10 - } - - # Handle output based on parameters - if ($DryRun) { - Write-Host "`nDry Run - Configuration would be saved as:" -ForegroundColor Cyan - Write-Host $yamlOutput -ForegroundColor White - Write-Host "`nNo files were modified (dry run mode)." -ForegroundColor Yellow - } else { - # Determine output file - $outputFile = if ($OutFile) { $OutFile } else { $Config } - - try { - Write-Debug "`nSaving configuration to: $outputFile" - $yamlOutput | Out-File -FilePath $outputFile - Write-Debug "Configuration saved successfully!" - } - catch { - Write-Error "Failed to save configuration to $outputFile`: $_" - return $false - } - } - - Write-Host "Scoop packages conversion completed!" -ForegroundColor Green - return $true - } - catch { - Write-Error "Error converting Scoop packages: $_" - return $false - } -} diff --git a/DevSetup/Private/Providers/Scoop/Get-ScoopComponentsInstalled.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Get-ScoopComponentsInstalled.Tests.ps1 new file mode 100644 index 0000000..b018c51 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Get-ScoopComponentsInstalled.Tests.ps1 @@ -0,0 +1,261 @@ +BeforeAll { + . $PSScriptRoot\Get-ScoopComponentsInstalled.ps1 + . $PSScriptRoot\Test-ScoopInstalled.ps1 + . $PSScriptRoot\Find-Scoop.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + Mock Write-StatusMessage { } + Mock Write-Host {} + Mock Write-Error {} +} + +Describe "Get-ScoopComponentsInstalled" { + + Context "When Scoop is not installed" { + It "Should return null and warn about Scoop not being installed" { + Mock Test-ScoopInstalled { return $false } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Scoop is not installed. Cannot check for installed components." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Test-ScoopInstalled throws an exception" { + It "Should return null and log error with stack trace" { + Mock Test-ScoopInstalled { throw "Critical error checking Scoop" } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not get installed Scoop components: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When Find-Scoop returns null" { + It "Should return null and warn about failing to find Scoop command" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return $null } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to find Scoop command. Cannot check for installed components." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Find-Scoop throws an exception" { + It "Should return null and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { throw "Error finding Scoop executable" } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Error finding Scoop command: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When scoop export command fails with non-zero exit code" { + It "Should return null and warn about no components found" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 1 + return $null + } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "No Scoop components found or scoop list command failed." -and $Verbosity -eq "Warning" + } + } + } + + Context "When scoop export returns empty results" { + It "Should return null and warn about no components found" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "No Scoop components found or scoop list command failed." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Invoke-Command throws an exception" { + It "Should return null and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { throw "Command execution failed" } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not execute 'scoop export': *" -and $Verbosity -eq "Error" + } + } + } + + Context "When ConvertFrom-Json fails with invalid JSON" { + It "Should return null and log JSON parsing error" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return "invalid json content" + } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not parse 'scoop export' output: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When everything succeeds with valid JSON" { + It "Should return parsed components list" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + $mockScoopExport = @{ + apps = @( + @{ + Name = "git" + Info = "" + Source = "main" + Updated = "2023-01-01T00:00:00.000Z" + Version = "2.39.0.windows.2" + }, + @{ + Name = "nodejs" + Info = "" + Source = "main" + Updated = "2023-01-15T00:00:00.000Z" + Version = "18.13.0" + } + ) + buckets = @( + @{ + Name = "main" + Source = "https://github.com/ScoopInstaller/Main" + Updated = "2023-01-01T00:00:00.000Z" + }, + @{ + Name = "extras" + Source = "https://github.com/ScoopInstaller/Extras" + Updated = "2023-01-10T00:00:00.000Z" + } + ) + } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return ($mockScoopExport | ConvertTo-Json -Depth 10) + } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Not -Be $null + $result.apps | Should -HaveCount 2 + $result.buckets | Should -HaveCount 2 + $result.apps[0].Name | Should -Be "git" + $result.apps[0].Version | Should -Be "2.39.0.windows.2" + $result.apps[1].Name | Should -Be "nodejs" + $result.buckets[0].Name | Should -Be "main" + $result.buckets[1].Name | Should -Be "extras" + } + } + + Context "When scoop export returns minimal valid JSON" { + It "Should return parsed components with empty arrays" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + $mockScoopExport = @{ + apps = @() + buckets = @() + } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return ($mockScoopExport | ConvertTo-Json -Depth 10) + } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Not -Be $null + $result.apps | Should -HaveCount 0 + $result.buckets | Should -HaveCount 0 + } + } + + Context "When scoop export returns JSON with only apps" { + It "Should return parsed components with apps but no buckets property" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + $mockScoopExport = @{ + apps = @( + @{ + Name = "curl" + Version = "7.87.0_1" + Source = "main" + } + ) + } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return ($mockScoopExport | ConvertTo-Json -Depth 10) + } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Not -Be $null + $result.apps | Should -HaveCount 1 + $result.apps[0].Name | Should -Be "curl" + $result.PSObject.Properties.Name -contains "buckets" | Should -Be $false + } + } + + Context "Integration test with mocked global LASTEXITCODE" { + It "Should properly handle LASTEXITCODE from scoop export command" { + # Ensure LASTEXITCODE starts clean + $global:LASTEXITCODE = 0 + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { + # Simulate scoop export failing + $global:LASTEXITCODE = 2 + return $null + } + + $result = Get-ScoopComponentsInstalled + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No Scoop components found or scoop list command failed." -and $Verbosity -eq "Warning" + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Get-ScoopComponentsInstalled.ps1 b/DevSetup/Private/Providers/Scoop/Get-ScoopComponentsInstalled.ps1 new file mode 100644 index 0000000..d73f6ed --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Get-ScoopComponentsInstalled.ps1 @@ -0,0 +1,50 @@ +Function Get-ScoopComponentsInstalled { + [CmdletBinding()] + [OutputType([hashtable])] + Param() + + try { + if(-Not (Test-ScoopInstalled)) { + Write-StatusMessage "Scoop is not installed. Cannot check for installed components." -Verbosity "Warning" + return $null + } + } catch { + Write-StatusMessage "Could not get installed Scoop components: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $scoopCommand = Find-Scoop + if (-not $scoopCommand) { + Write-StatusMessage "Failed to find Scoop command. Cannot check for installed components." -Verbosity "Warning" + return $null + } + } catch { + Write-StatusMessage "Error finding Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $scoopListResults = Invoke-Command -ScriptBlock {& $scoopCommand export } + if ($LASTEXITCODE -ne 0 -or -not $scoopListResults) { + Write-StatusMessage "No Scoop components found or scoop list command failed." -Verbosity Warning + return $null + } + } catch { + Write-StatusMessage "Could not execute 'scoop export': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $scoopComponentsList = $scoopListResults | ConvertFrom-Json + } catch { + Write-StatusMessage "Could not parse 'scoop export' output: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + return $scoopComponentsList +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Get-ScoopPackagesAvailable.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Get-ScoopPackagesAvailable.Tests.ps1 new file mode 100644 index 0000000..36bcba9 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Get-ScoopPackagesAvailable.Tests.ps1 @@ -0,0 +1,335 @@ +BeforeAll { + . $PSScriptRoot\Get-ScoopPackagesAvailable.ps1 + . $PSScriptRoot\Test-ScoopInstalled.ps1 + . $PSScriptRoot\Find-Scoop.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + Mock Write-StatusMessage { } + Mock Write-Host {} + Mock Write-Error {} +} + +Describe "Get-ScoopPackagesAvailable" { + + Context "When Scoop is not installed" { + It "Should return null and warn about Scoop not being installed" { + Mock Test-ScoopInstalled { return $false } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Scoop is not installed. Cannot check for available packages." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Test-ScoopInstalled throws an exception" { + It "Should return null and log error with stack trace" { + Mock Test-ScoopInstalled { throw "Critical error checking Scoop" } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not get available Scoop packages: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When Find-Scoop returns null" { + It "Should return null and warn about failing to find Scoop command" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return $null } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to find Scoop command. Cannot check for available packages." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Find-Scoop throws an exception" { + It "Should return null and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { throw "Error finding Scoop executable" } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Error finding Scoop command: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When scoop search command fails with non-zero exit code" { + It "Should return null and warn about no packages found" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 1 + return $null + } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "No Scoop packages found or scoop search command failed." -and $Verbosity -eq "Warning" + } + } + } + + Context "When scoop search returns empty results" { + It "Should return null and warn about no packages found" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "No Scoop packages found or scoop search command failed." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Invoke-Command throws an exception" { + It "Should return null and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { throw "Command execution failed" } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not execute 'scoop search': *" -and $Verbosity -eq "Error" + } + } + } + + Context "When parsing scoop search output fails" { + It "Should return null and log parsing error" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + # Mock search output that would cause parsing issues + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("Searching in all buckets...", "", "Results from local buckets...", "", "git") + } + # Mock the parsing logic to throw an exception + Mock ForEach-Object { throw "Parsing error" } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not parse 'scoop search' output: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When scoop search returns valid output with packages" { + It "Should return parsed packages hashtable with correct structure" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + # Mock typical scoop search output + $mockSearchOutput = @( + "Searching in all buckets...", + "", + "Results from local buckets...", + "", + "git 2.39.0.windows.2 main", + "nodejs 18.13.0 main", + "python 3.11.1 main", + "vscode 1.74.2 extras" + ) + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $mockSearchOutput + } + + $result = Get-ScoopPackagesAvailable + + # Now the function works correctly and returns a hashtable + $result | Should -Not -Be $null + $result | Should -BeOfType [hashtable] + $result.Keys.Count | Should -Be 4 + $result["git"] | Should -Not -Be $null + $result["git"].Name | Should -Be "git" + $result["git"].Version | Should -Be "2.39.0.windows.2" + $result["git"].Source | Should -Be "main" + $result["nodejs"].Version | Should -Be "18.13.0" + $result["vscode"].Source | Should -Be "extras" + } + } + + Context "When scoop search returns header-only output" { + It "Should return empty hashtable when no packages are found after headers" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + # Mock search output with only headers + $mockSearchOutput = @( + "Searching in all buckets...", + "", + "Results from local buckets...", + "" + ) + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $mockSearchOutput + } + + $result = Get-ScoopPackagesAvailable + + # The function should return an empty hashtable since no packages are found after skipping headers + $result | Should -Not -Be $null + $result | Should -BeOfType [hashtable] + $result.Keys.Count | Should -Be 0 + } + } + + Context "When scoop search returns malformed package lines" { + It "Should handle malformed package lines gracefully and process valid ones" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + # Mock search output with various malformed lines + $mockSearchOutput = @" +Searching in all buckets... + +Results from local buckets... + +git 2.39.0.windows.2 main + + +incomplete-package +another-package 1.0.0 +full-package 2.0.0 extras +"@ + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $mockSearchOutput + } + + $result = Get-ScoopPackagesAvailable + + # The function should handle malformed lines gracefully and process valid ones + $result | Should -Not -Be $null + $result | Should -BeOfType [hashtable] + # Should have processed valid entries + $result["git"] | Should -Not -Be $null + $result["git"].Version | Should -Be "2.39.0.windows.2" + $result["another-package"] | Should -Not -Be $null + $result["another-package"].Version | Should -Be "1.0.0" + $result["full-package"] | Should -Not -Be $null + $result["full-package"].Source | Should -Be "extras" + # Single-word entries might still be processed depending on $Parts.Count check + if ($result["incomplete-package"]) { + $result["incomplete-package"].Name | Should -Be "incomplete-package" + } + } + } + + Context "Integration test with mocked global LASTEXITCODE" { + It "Should properly handle LASTEXITCODE from scoop search command" { + # Ensure LASTEXITCODE starts clean + $global:LASTEXITCODE = 0 + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Invoke-Command { + # Simulate scoop search failing + $global:LASTEXITCODE = 2 + return $null + } + + $result = Get-ScoopPackagesAvailable + + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No Scoop packages found or scoop search command failed." -and $Verbosity -eq "Warning" + } + } + } + + Context "When scoop search returns mixed valid and invalid lines" { + It "Should process valid lines and handle invalid ones gracefully" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + $mockSearchOutput = @" +Searching in all buckets... + +Results from local buckets... + +git 2.39.0.windows.2 main +broken-line-no-spaces +nodejs 18.13.0 main + +python 3.11.1 main +"@ + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $mockSearchOutput + } + + $result = Get-ScoopPackagesAvailable + + # Should process all non-empty lines that pass the $Parts.Count > 0 check + $result | Should -Not -Be $null + $result | Should -BeOfType [hashtable] + $result["git"] | Should -Not -Be $null + $result["nodejs"] | Should -Not -Be $null + $result["python"] | Should -Not -Be $null + $result["git"].Version | Should -Be "2.39.0.windows.2" + $result["nodejs"].Version | Should -Be "18.13.0" + # Single-word entry may or may not be processed depending on implementation + if ($result["broken-line-no-spaces"]) { + $result["broken-line-no-spaces"].Name | Should -Be "broken-line-no-spaces" + } + } + } + + Context "Performance test with large search results" { + It "Should handle large search results efficiently and return proper hashtable" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + + # Generate a large mock search output + $mockSearchOutput = @("Searching in all buckets...", "", "Results from local buckets...", "") + for ($i = 1; $i -le 1000; $i++) { + $mockSearchOutput += "package$i 1.0.$i main" + } + + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $mockSearchOutput + } + + # Measure execution time + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $result = Get-ScoopPackagesAvailable + $stopwatch.Stop() + + # Function should return a hashtable with all 1000 packages + $result | Should -Not -Be $null + $result | Should -BeOfType [hashtable] + $result.Keys.Count | Should -Be 1000 + $result["package1"] | Should -Not -Be $null + $result["package1000"] | Should -Not -Be $null + + # Verify it completes in reasonable time (less than 10 seconds) + $stopwatch.ElapsedMilliseconds | Should -BeLessThan 10000 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Get-ScoopPackagesAvailable.ps1 b/DevSetup/Private/Providers/Scoop/Get-ScoopPackagesAvailable.ps1 new file mode 100644 index 0000000..0b2a5df --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Get-ScoopPackagesAvailable.ps1 @@ -0,0 +1,69 @@ +Function Get-ScoopPackagesAvailable { + [CmdletBinding()] + [OutputType([hashtable])] + Param() + + try { + if(-Not (Test-ScoopInstalled)) { + Write-StatusMessage "Scoop is not installed. Cannot check for available packages." -Verbosity "Warning" + return $null + } + } catch { + Write-StatusMessage "Could not get available Scoop packages: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $scoopCommand = Find-Scoop + if (-not $scoopCommand) { + Write-StatusMessage "Failed to find Scoop command. Cannot check for available packages." -Verbosity "Warning" + return $null + } + } catch { + Write-StatusMessage "Error finding Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + try { + $scoopSearchResults = Invoke-Command -ScriptBlock {& $scoopCommand search } 6>$null | Out-String + if ($LASTEXITCODE -ne 0 -or -not $scoopSearchResults) { + Write-StatusMessage "No Scoop packages found or scoop search command failed." -Verbosity Warning + return $null + } + } catch { + Write-StatusMessage "Could not execute 'scoop search': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + $scoopPackages = @{} + + try { + $scoopSearchResults -Split "`n" | select-object -skip 4 | Foreach-Object { + $Parts = $_.Trim() -Split "\s+"; + $NewParts = @($Parts | Where-Object { + $_ -ne $null -and $_ -ne "" + }); + if($NewParts.Count -gt 0) { + $packageName = $NewParts[0] + $packageVersion = if ($NewParts.Count -gt 1) { $NewParts[1] } else { $null } + $packageSource = if ($NewParts.Count -gt 2) { $NewParts[2] } else { $null } + + $scoopPackages[$packageName] = @{ + Name = $packageName + Version = $packageVersion + Source = $packageSource + } + } + } + } catch { + Write-StatusMessage "Could not parse 'scoop search' output: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } + + return $scoopPackages + +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Install-Scoop.ps1 b/DevSetup/Private/Providers/Scoop/Install-Scoop.ps1 index 5baa4cf..e85f7fc 100644 --- a/DevSetup/Private/Providers/Scoop/Install-Scoop.ps1 +++ b/DevSetup/Private/Providers/Scoop/Install-Scoop.ps1 @@ -63,7 +63,10 @@ #> Function Install-Scoop { [CmdletBinding()] - Param () + Param ( + [Parameter(Mandatory = $false)] + [switch]$DryRun + ) Write-StatusMessage "- Installing Scoop package manager" -ForegroundColor Gray -Indent 2 -Width 77 -NoNewline if(-not (Test-ScoopInstalled)) { diff --git a/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.Tests.ps1 index 24a4ac9..bc1581b 100644 --- a/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.Tests.ps1 +++ b/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.Tests.ps1 @@ -4,87 +4,343 @@ BeforeAll { . $PSScriptRoot\Test-ScoopComponentInstalled.ps1 . $PSScriptRoot\Find-Scoop.ps1 . $PSScriptRoot\Write-ScoopCache.ps1 + . $PSScriptRoot\..\..\Utils\Write-StatusMessage.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 } Describe "Install-ScoopBucket" { - Context "When scoop is not installed" { - It "Should return false" { + BeforeEach { + $global:LASTEXITCODE = 0 + Mock Write-StatusMessage { } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Write-ScoopCache { return $true } + } + + Context "When Test-ScoopInstalled returns false" { + BeforeEach { Mock Test-ScoopInstalled { return $false } + } + + It "Should return false" { $result = Install-ScoopBucket -Name "extras" $result | Should -Be $false } - } - Context "When scoop is not found" { + + It "Should not call Find-Scoop" { + Install-ScoopBucket -Name "extras" + Should -Not -Invoke Find-Scoop + } + } + + Context "When Test-ScoopInstalled throws an exception" { + BeforeEach { + Mock Test-ScoopInstalled { throw "Scoop check failed" } + } + It "Should return false" { + $result = Install-ScoopBucket -Name "extras" + $result | Should -Be $false + } + + It "Should log error message and stack trace" { + Install-ScoopBucket -Name "extras" + Should -Invoke Write-StatusMessage -Times 2 -ParameterFilter { $Verbosity -eq "Error" } + } + } + + Context "When Find-Scoop returns null" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return $null } + } + + It "Should return false" { $result = Install-ScoopBucket -Name "extras" $result | Should -Be $false } + + It "Should not call Test-ScoopComponentInstalled" { + Install-ScoopBucket -Name "extras" + Should -Not -Invoke Test-ScoopComponentInstalled + } } - Context "When a Bucket is already installed" { - It "Should return true" { + + Context "When Find-Scoop throws an exception" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { throw "Find Scoop failed" } + } + + It "Should return false" { + $result = Install-ScoopBucket -Name "extras" + $result | Should -Be $false + } + + It "Should log error message and stack trace" { + Install-ScoopBucket -Name "extras" + Should -Invoke Write-StatusMessage -Times 2 -ParameterFilter { $Verbosity -eq "Error" } + } + } + + Context "When Test-ScoopComponentInstalled throws an exception" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { throw "Component check failed" } + } + + It "Should return false" { + $result = Install-ScoopBucket -Name "extras" + $result | Should -Be $false + } + + It "Should log error message with bucket name" { + Install-ScoopBucket -Name "extras" + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to check if Scoop bucket 'extras' is installed*" -and $Verbosity -eq "Error" + } + } + } + + Context "When bucket is already installed" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::Pass } + } + + It "Should return true" { $result = Install-ScoopBucket -Name "extras" $result | Should -Be $true } - } - Context "When a Bucket is not already installed and it fails to install it" { - It "Should return false" { + + It "Should not execute Invoke-Command" { + Install-ScoopBucket -Name "extras" + Should -Not -Invoke Invoke-Command + } + + It "Should not call Write-ScoopCache" { + Install-ScoopBucket -Name "extras" + Should -Not -Invoke Write-ScoopCache + } + } + + Context "When bucket is not installed and installation is successful" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + Mock Write-ScoopCache { return $true } + } + + It "Should install official bucket successfully" { + $result = Install-ScoopBucket -Name "extras" + $result | Should -Be $true + Should -Invoke Invoke-Command -Times 1 + } + + It "Should install custom bucket with source successfully" { + $result = Install-ScoopBucket -Name "custom-bucket" -Source "https://github.com/user/scoop-bucket" + $result | Should -Be $true + Should -Invoke Invoke-Command -Times 1 + } + + It "Should update cache after successful installation" { + Install-ScoopBucket -Name "extras" + Should -Invoke Write-ScoopCache -Times 1 + } + } + + Context "When bucket installation fails with non-zero exit code" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } Mock Invoke-Command { $global:LASTEXITCODE = 1 return $null - } -Verifiable + } + Mock Write-ScoopCache { return $true } + } + + It "Should return false" { $result = Install-ScoopBucket -Name "extras" $result | Should -Be $false } + + It "Should not call Write-ScoopCache when installation fails" { + Install-ScoopBucket -Name "extras" + Should -Not -Invoke Write-ScoopCache + } } - Context "When a Bucket is not already installed and it gets installed but fails to write the cache" { - It "Should return false" { + Context "When bucket installation succeeds but cache update fails" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } Mock Invoke-Command { $global:LASTEXITCODE = 0 return $null - } -Verifiable + } Mock Write-ScoopCache { return $false } + } + + It "Should return false" { $result = Install-ScoopBucket -Name "extras" $result | Should -Be $false } + + It "Should attempt cache update" { + Install-ScoopBucket -Name "extras" + Should -Invoke Write-ScoopCache -Times 1 + } } - Context "When a Bucket is not already installed and installing it causes an error to be thrown" { - It "Should return false" { + + Context "When cache update throws an exception" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } Mock Invoke-Command { - throw 'Failed' - } -Verifiable - Mock Write-ScoopCache { return $true } + $global:LASTEXITCODE = 0 + return $null + } + Mock Write-ScoopCache { throw "Cache update failed" } + } + + It "Should return false" { $result = Install-ScoopBucket -Name "extras" $result | Should -Be $false } - } - Context "When a Bucket is not already installed and it gets installed and writes the cache" { - It "Should return true" { + + It "Should log cache update error with bucket name" { + Install-ScoopBucket -Name "extras" + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to update Scoop cache after adding bucket 'extras'*" -and $Verbosity -eq "Error" + } + } + } + + Context "When using WhatIf parameter" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } Mock Invoke-Command { $global:LASTEXITCODE = 0 return $null - } -Verifiable + } Mock Write-ScoopCache { return $true } - $result = Install-ScoopBucket -Name "extras" + } + + It "Should return true with WhatIf when bucket not already installed" { + $result = Install-ScoopBucket -Name "extras" -WhatIf + $result | Should -Be $true + } + + It "Should still check if bucket is already installed" { + Install-ScoopBucket -Name "extras" -WhatIf + Should -Invoke Test-ScoopComponentInstalled -Times 1 + } + + It "Should update cache even with WhatIf when bucket not installed" { + Install-ScoopBucket -Name "extras" -WhatIf + Should -Invoke Write-ScoopCache -Times 1 + } + + It "Should return true with WhatIf when bucket already installed" { + Mock Test-ScoopComponentInstalled { return [InstalledState]::Pass } + $result = Install-ScoopBucket -Name "extras" -WhatIf $result | Should -Be $true } - } + } + + Context "Parameter validation and edge cases" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + Mock Write-ScoopCache { return $true } + } + + It "Should handle empty source parameter correctly" { + $result = Install-ScoopBucket -Name "extras" -Source "" + $result | Should -Be $true + Should -Invoke Invoke-Command -Times 1 + } + + It "Should pass correct parameters to Test-ScoopComponentInstalled" { + Install-ScoopBucket -Name "test-bucket" + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Bucket -eq $true -and $Name -eq "test-bucket" + } + } + + It "Should handle bucket names with special characters" { + $result = Install-ScoopBucket -Name "test-bucket-123" + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Name -eq "test-bucket-123" + } + } + } + + Context "Integration test scenarios" { + It "Should handle complete successful installation flow" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "C:\Users\Test\scoop\shims\scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + Mock Write-ScoopCache { return $true } + + $result = Install-ScoopBucket -Name "nonportable" -Source "https://github.com/ScoopInstaller/Nonportable" + + $result | Should -Be $true + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Invoke Find-Scoop -Times 1 + Should -Invoke Test-ScoopComponentInstalled -Times 1 + Should -Invoke Invoke-Command -Times 1 + Should -Invoke Write-ScoopCache -Times 1 + } + + It "Should handle complete failure scenario with error logging" { + Mock Test-ScoopInstalled { throw "Test failure" } + + $result = Install-ScoopBucket -Name "extras" + + $result | Should -Be $false + Should -Invoke Write-StatusMessage -Times 2 -ParameterFilter { $Verbosity -eq "Error" } + Should -Not -Invoke Find-Scoop + Should -Not -Invoke Test-ScoopComponentInstalled + Should -Not -Invoke Invoke-Command + Should -Not -Invoke Write-ScoopCache + } + + It "Should handle early exit when scoop not installed" { + Mock Test-ScoopInstalled { return $false } + + $result = Install-ScoopBucket -Name "extras" + + $result | Should -Be $false + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Not -Invoke Find-Scoop + Should -Not -Invoke Test-ScoopComponentInstalled + Should -Not -Invoke Invoke-Command + Should -Not -Invoke Write-ScoopCache + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.ps1 b/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.ps1 index d37f655..39db9f2 100644 --- a/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.ps1 +++ b/DevSetup/Private/Providers/Scoop/Install-ScoopBucket.ps1 @@ -67,50 +67,70 @@ Bucket Management, Repository Addition #> Function Install-ScoopBucket { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [string]$Name, [string]$Source ) - if(-Not (Test-ScoopInstalled)) { + try { + if(-Not (Test-ScoopInstalled)) { + return $false + } + } catch { + Write-StatusMessage "Scoop is not installed. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } - $scoopCommand = Find-Scoop - if (-Not ($scoopCommand)) { + try { + $scoopCommand = Find-Scoop + if (-Not ($scoopCommand)) { + return $false + } + } catch { + Write-StatusMessage "Failed to find Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } try { [InstalledState]$bucketState = Test-ScoopComponentInstalled -Bucket -Name $Name - if ($bucketState -ne [InstalledState]::Pass) { - $installArgs = @("bucket", "add", $Name) - - # If a source is provided, add it to the command arguments - if ($Source) { - $installArgs += $Source - } - - # Execute the command to add the bucket - $command = { - & $scoopCommand @installArgs *> $null - } - Invoke-Command -ScriptBlock $command | Out-Null - if ($LASTEXITCODE -ne 0) { - return $false - } + } catch { + Write-StatusMessage "Failed to check if Scoop bucket '$Name' is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + if ($bucketState -ne [InstalledState]::Pass) { + $installArgs = @("bucket", "add", $Name) + + # If a source is provided, add it to the command arguments + if ($Source) { + $installArgs += $Source + } + + if ($PSCmdlet.ShouldProcess($Name, "Add Scoop Bucket")) { + Invoke-Command -ScriptBlock { & $scoopCommand @installArgs } *> $null + } + + if ($LASTEXITCODE -ne 0) { + return $false + } + + try { if (-not (Write-ScoopCache)) { return $false - } - - return $true - } else { - return $true - } - } catch { - return $false + } + } catch { + Write-StatusMessage "Failed to update Scoop cache after adding bucket '$Name': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + return $true + } else { + return $true } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Install-ScoopComponents.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Install-ScoopComponents.Tests.ps1 deleted file mode 100644 index d59c1c3..0000000 --- a/DevSetup/Private/Providers/Scoop/Install-ScoopComponents.Tests.ps1 +++ /dev/null @@ -1,123 +0,0 @@ -BeforeAll { - . $PSScriptRoot\Install-ScoopComponents.ps1 - . $PSScriptRoot\Test-ScoopInstalled.ps1 - . $PSScriptRoot\Write-ScoopCache.ps1 - . $PSScriptRoot\Install-ScoopBucket.ps1 - . $PSScriptRoot\Install-ScoopPackage.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 - Mock Write-StatusMessage { } - Mock Write-Host {} - Mock Write-Error {} -} - -Describe "Install-ScoopComponents" { - - Context "When Scoop is not installed" { - It "Should return false and warn" { - Mock Test-ScoopInstalled { return $false } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ - buckets = @("extras") - packages = @("git") } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When Scoop configuration is missing" { - It "Should return false and warn" { - Mock Test-ScoopInstalled { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When Write-ScoopCache fails" { - It "Should return false and error" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $false } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ - buckets = @("extras") - packages = @("git") } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When only buckets are present and all install succeed" { - It "Should return true and process all buckets" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - Mock Install-ScoopBucket { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ buckets = @("extras", "versions") } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When only packages are present and all install succeed" { - It "Should return true and process all packages" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - Mock Install-ScoopPackage { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ packages = @("git", "nodejs") } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When buckets and packages are present and some installs fail" { - It "Should return true and report failures" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - $bucketCallCount = 0 - Mock Install-ScoopBucket -MockWith { - $bucketCallCount++ - if ($bucketCallCount -eq 1) { return $false } else { return $true } - } - $packageCallCount = 0 - Mock Install-ScoopPackage -MockWith { - $packageCallCount++ - if ($packageCallCount -eq 2) { return $false } else { return $true } - } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ - buckets = @("extras", "versions") - packages = @("git", "nodejs") } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When no buckets or packages are present" { - It "Should return true and skip package installation" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When an exception occurs during package install" { - It "Should catch and continue, returning true" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - Mock Install-ScoopBucket { return $true } - Mock Install-ScoopPackage { throw "Unexpected error" } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ packages = @("git", "nodejs") } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When an exception occurs in the main try block" { - It "Should return false" { - Mock Test-ScoopInstalled { throw "Critical error" } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ - buckets = @("extras") - packages = @("git") } } } } - $result = Install-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Install-ScoopComponents.ps1 b/DevSetup/Private/Providers/Scoop/Install-ScoopComponents.ps1 deleted file mode 100644 index fb4b812..0000000 --- a/DevSetup/Private/Providers/Scoop/Install-ScoopComponents.ps1 +++ /dev/null @@ -1,256 +0,0 @@ -๏ปฟ<# -.SYNOPSIS - Installs Scoop buckets and packages from YAML configuration data. - -.DESCRIPTION - This function processes YAML configuration data to install Scoop buckets and packages in sequence. - It validates Scoop installation, updates the cache before proceeding, and processes buckets before - packages to ensure bucket availability. The function supports both simple string formats and complex - object formats for buckets and packages, allowing for detailed configuration including versions, - custom sources, and global installation scope. Progress is tracked and reported for both buckets - and packages using color-coded status messages. - -.PARAMETER YamlData - The YAML configuration data containing Scoop bucket and package definitions. - This parameter is mandatory and must be a PSCustomObject with the structure: - devsetup.dependencies.scoop.buckets and/or devsetup.dependencies.scoop.packages - -.OUTPUTS - [System.Boolean] - Returns $false if Scoop is not installed, cannot be found, configuration is invalid, or cache update fails. - Returns $true if installation completes successfully (even if individual items fail). - -.EXAMPLE - $yamlData = Get-Content "config.yaml" | ConvertFrom-Yaml - Install-ScoopComponents -YamlData $yamlData - - Installs Scoop buckets and packages from a YAML configuration file. - -.EXAMPLE - $yamlData = @{ - devsetup = @{ - dependencies = @{ - scoop = @{ - buckets = @( - "extras", - @{ - name = "custom-bucket" - source = "https://github.com/user/scoop-bucket" - } - ) - packages = @( - "git", - @{ - name = "nodejs" - version = "18.17.0" - }, - @{ - name = "7zip" - global = $true - }, - @{ - name = "firefox" - bucket = "extras" - } - ) - } - } - } - } - Install-ScoopComponents -YamlData $yamlData - - Demonstrates the PSCustomObject structure and installs the configured components. - -.EXAMPLE - if (Install-ScoopComponents -YamlData $config) { - Write-Host "Scoop components installation completed" - } else { - Write-Host "Scoop components installation failed" - } - - Shows checking the return value to verify installation completion. - -.NOTES - - Requires Scoop to be installed on the system using Test-ScoopInstalled - - Returns $false immediately if Scoop is not installed or cannot be found - - Returns $false if YAML configuration structure is invalid or missing scoop section - - Updates Scoop cache using Write-ScoopCache before installation begins - - Returns $false if cache update fails to ensure accurate installation state - - Processes buckets before packages to ensure bucket availability for package installations - - Gracefully handles missing buckets or packages sections in configuration - - Supports two bucket specification formats: - * Simple string: "bucketname" - * Complex object: @{ name = "bucketname"; source = "https://github.com/user/scoop-bucket" } - - Supports two package specification formats: - * Simple string: "packagename" - * Complex object: @{ name = "packagename"; version = "1.0.0"; bucket = "extras"; global = $true } - - Validates component names and skips entries with missing names - - Uses Install-ScoopBucket and Install-ScoopPackage functions for actual installation - - Provides detailed progress reporting with component counts and property information - - Uses color-coded console output: Cyan for headers, Gray for items, Green/Red for status - - Displays formatted component information including version, bucket, and global flags - - Continues processing remaining components even if individual installations fail - - Returns $true for overall success even with individual component failures - - Includes comprehensive try-catch error handling with descriptive error messages - - Tracks and reports separate counts for buckets and packages processed - -.LINK - -.COMPONENT - DevSetup.Scoop - -.FUNCTIONALITY - Bulk Installation, Configuration Processing, Package Management -#> -Function Install-ScoopComponents { - [CmdletBinding()] - Param( - [Parameter(Mandatory=$true)] - [PSCustomObject]$YamlData - ) - - try { - if(-Not (Test-ScoopInstalled)) { - Write-StatusMessage "Scoop is not installed. Cannot check for components." -Verbosity "Warning" - return $false - } - - # Check if scoop packages exist in configuration - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.scoop) { - Write-StatusMessage "Scoop configuration not found in YAML. Skipping installation." -Verbosity "Warning" - return $false - } - - if (-not (Write-ScoopCache)) { - Write-Error "Failed to write Scoop cache file: $CacheFilePath" - return $false - } - - $bucketCount = 0 - Write-StatusMessage "- Installing Scoop buckets from configuration:" -ForegroundColor Cyan - # Handle buckets first if they exist in configuration - if ($YamlData.devsetup.dependencies.scoop.buckets) { - foreach ($bucket in $YamlData.devsetup.dependencies.scoop.buckets) { - if (-not $bucket) { continue } - - # Handle both string format and object format - $bucketName = if ($bucket -is [string]) { $bucket } else { $bucket.name } - $bucketSource = if ($bucket -is [hashtable] -and $bucket.source) { $bucket.source } else { $null } - - $installParams = @{ - Name = $bucketName - } - - if ($bucketSource) { - $installParams.Source = $bucketSource - } - - # Use Install-ScoopBucket function to handle bucket installation - if ($bucketName -and $bucketSource) { - Write-StatusMessage "- Adding Scoop bucket: $bucketName (source: $bucketSource)" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine - } else { - Write-StatusMessage "- Adding Scoop bucket: $bucketName" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine - } - - $installationStatus = Install-ScoopBucket @installParams - - if (-not $installationStatus) { - Write-StatusMessage "[FAILED]" -ForegroundColor Red - } else { - $bucketCount++ - Write-StatusMessage "[OK]" -ForegroundColor Green - } - } - } - - Write-StatusMessage "- Scoop buckets installation completed! Processed $bucketCount buckets." -ForegroundColor Green - - Write-Host "" - - # Check if scoop packages exist in configuration - if (-not $YamlData.devsetup.dependencies.scoop.packages) { - Write-StatusMessage "Scoop packages not found in YAML configuration. Skipping package installation." -Verbosity "Warning" - return $true - } - - $scoopPackages = $YamlData.devsetup.dependencies.scoop.packages - Write-StatusMessage "- Installing Scoop packages from configuration:" -ForegroundColor Cyan - - $packageCount = 0 - - # Install packages - foreach ($package in $scoopPackages) { - if (-not $package) { continue } - - $packageCount++ - - # Normalize package to object format - if ($package -is [string]) { - $packageObj = @{ name = $package } - } else { - $packageObj = $package - } - - # Validate package name - if ([string]::IsNullOrEmpty($packageObj.name)) { - Write-StatusMessage "- Skipping package entry, No name specified" -Verbosity "Warning" -Indent 2 -Width 112 - continue - } - - # Use Install-ScoopPackage function to handle the installation - try { - $displayName = $packageObj.name - $installParams = @{ - PackageName = $packageObj.name - } - - $versionDisplay = "" - if ($packageObj.version) { - $versionDisplay = "version: $($packageObj.version)" - $installParams.Version = $packageObj.version - } - - $bucketDisplay = "" - if ($packageObj.bucket) { - $bucketDisplay = "bucket: '$($packageObj.bucket)'" - $installParams.Bucket = $packageObj.bucket - } - - $globalDisplay = "" - if ($packageObj.global -eq $true) { - $globalDisplay = "global: true" - $installParams.Global = $true - } else { - $installParams.Global = $false - } - - if($versionDisplay -or $bucketDisplay -or $globalDisplay) { - $parts = @($versionDisplay, $bucketDisplay, $globalDisplay) | Where-Object { $_ } - $displayName += " (" + ($parts -join ", ") + ")" - } - Write-StatusMessage "- Installing Scoop package: $displayName" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine - - $result = Install-ScoopPackage @installParams 2>$null 3>$null 4>$null 5>$null 6>$null - - if (-not $result) { - Write-StatusMessage "[FAILED]" -ForegroundColor Red - } else { - Write-StatusMessage "[OK]" -ForegroundColor Green - } - } catch { - Write-StatusMessage "Failed to install Scoop package '$($packageObj.name)': $_" -Verbosity "Error" - continue - } - } - - Write-StatusMessage "- Scoop packages installation completed! Processed $packageCount packages." -ForegroundColor Green - - Write-Host "" - - return $true - } - catch { - Write-StatusMessage "Error installing Scoop packages: $_" -Verbosity "Error" - return $false - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.Tests.ps1 index 7c3f0df..f55e4ae 100644 --- a/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.Tests.ps1 +++ b/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.Tests.ps1 @@ -5,117 +5,538 @@ BeforeAll { . $PSScriptRoot\Test-ScoopComponentInstalled.ps1 . $PSScriptRoot\Uninstall-ScoopPackage.ps1 . $PSScriptRoot\Write-ScoopCache.ps1 - . $PSScriptRoot\Read-ScoopCache.ps1 + . $PSScriptRoot\..\..\Utils\Write-StatusMessage.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 } Describe "Install-ScoopPackage" { + BeforeEach { + $global:LASTEXITCODE = 0 + Mock Write-StatusMessage { } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Uninstall-ScoopPackage { return $true } + Mock Write-ScoopCache { return $true } + } - Context "When Scoop is not installed" { - It "Should return false" { + Context "When Test-ScoopInstalled returns false" { + BeforeEach { Mock Test-ScoopInstalled { return $false } + } + + It "Should return false" { $result = Install-ScoopPackage -PackageName "git" $result | Should -Be $false } + + It "Should not call Find-Scoop" { + Install-ScoopPackage -PackageName "git" + Should -Not -Invoke Find-Scoop + } } - Context "When Scoop command cannot be found" { + Context "When Test-ScoopInstalled throws an exception" { + BeforeEach { + Mock Test-ScoopInstalled { throw "Scoop check failed" } + } + It "Should return false" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $false + } + + It "Should log error message and stack trace" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-StatusMessage -Times 2 -ParameterFilter { $Verbosity -eq "Error" } + } + } + + Context "When Find-Scoop returns null" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return $null } + } + + It "Should return false" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $false + } + + It "Should not call Test-ScoopComponentInstalled" { + Install-ScoopPackage -PackageName "git" + Should -Not -Invoke Test-ScoopComponentInstalled + } + } + + Context "When Find-Scoop throws an exception" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { throw "Find Scoop failed" } + } + + It "Should return false" { $result = Install-ScoopPackage -PackageName "git" $result | Should -Be $false } + + It "Should log error message and stack trace" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-StatusMessage -Times 2 -ParameterFilter { $Verbosity -eq "Error" } + } } - Context "When package is already installed with correct version and scope" { - It "Should return true" { + Context "When Test-ScoopComponentInstalled throws an exception" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { throw "Component check failed" } + } + + It "Should return false" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $false + } + + It "Should log error message with package name" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to check if Scoop package 'git' is installed*" -and $Verbosity -eq "Error" + } + } + } + + Context "When package is already installed correctly" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::Pass } + } + + It "Should return true without installing" { $result = Install-ScoopPackage -PackageName "git" $result | Should -Be $true } + + It "Should not execute Invoke-Command" { + Install-ScoopPackage -PackageName "git" + Should -Not -Invoke Invoke-Command + } + + It "Should not call Write-ScoopCache" { + Install-ScoopPackage -PackageName "git" + Should -Not -Invoke Write-ScoopCache + } + + It "Should not call Uninstall-ScoopPackage" { + Install-ScoopPackage -PackageName "git" + Should -Not -Invoke Uninstall-ScoopPackage + } } - Context "When package is installed but version/scope does not match" { - It "Should uninstall and reinstall the package" { + Context "When package is installed but needs reinstallation" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } - $callCount = 0 - Mock Test-ScoopComponentInstalled -MockWith { - $callCount++ - if ($callCount -eq 1) { [InstalledState]::Installed } + $script:callCount = 0 + Mock Test-ScoopComponentInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { [InstalledState]::Installed } else { [InstalledState]::Pass } } - Mock Read-ScoopCache { - return @{ - apps = @( - [PSCustomObject]@{ Name = "git"; Version = "2.42.0"; Info = "Local Install" } - ) - } - } Mock Uninstall-ScoopPackage { return $true } Mock Invoke-Command { $global:LASTEXITCODE = 0 } Mock Write-ScoopCache { return $true } + } + + It "Should uninstall and reinstall successfully" { $result = Install-ScoopPackage -PackageName "git" $result | Should -Be $true } + + It "Should call Uninstall-ScoopPackage" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Uninstall-ScoopPackage -Times 1 -ParameterFilter { $PackageName -eq "git" } + } + + It "Should call Test-ScoopComponentInstalled twice" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Test-ScoopComponentInstalled -Times 2 + } } - Context "When install command fails" { + Context "When uninstalling existing package fails" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::Installed } + Mock Uninstall-ScoopPackage { throw "Uninstall failed" } + } + It "Should return false" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $false + } + + It "Should log uninstall error with package name" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to uninstall existing Scoop package 'git'*" -and $Verbosity -eq "Error" + } + } + } + + Context "When fresh package installation is successful" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + $script:callCount = 0 + Mock Test-ScoopComponentInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { [InstalledState]::NotInstalled } + else { [InstalledState]::Pass } + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Write-ScoopCache { return $true } + } + + It "Should install basic package successfully" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $true + Should -Invoke Invoke-Command -Times 1 + } + + It "Should install package with version" { + $result = Install-ScoopPackage -PackageName "nodejs" -Version "18.17.0" + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Package -eq $true -and $Name -eq "nodejs" -and $Version -eq "18.17.0" + } + } + + It "Should install package with bucket" { + $result = Install-ScoopPackage -PackageName "firefox" -Bucket "extras" + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Package -eq $true -and $Name -eq "firefox" + } + } + + It "Should install package globally" { + $result = Install-ScoopPackage -PackageName "7zip" -Global + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Package -eq $true -and $Name -eq "7zip" -and $Global -eq $true + } + } + + It "Should install package with all parameters" { + $result = Install-ScoopPackage -PackageName "python" -Version "3.11.5" -Bucket "main" -Global + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Package -eq $true -and $Name -eq "python" -and $Version -eq "3.11.5" -and $Global -eq $true + } + } + + It "Should update cache after successful installation" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-ScoopCache -Times 1 + } + } + + Context "When installation command fails" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } - Mock Uninstall-ScoopPackage { return $true } Mock Invoke-Command { $global:LASTEXITCODE = 1 } + Mock Write-ScoopCache { return $true } + } + + It "Should return false when exit code is non-zero" { $result = Install-ScoopPackage -PackageName "git" $result | Should -Be $false } + + It "Should not call Write-ScoopCache when installation fails" { + Install-ScoopPackage -PackageName "git" + Should -Not -Invoke Write-ScoopCache + } + + It "Should not verify installation when command fails" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Test-ScoopComponentInstalled -Times 1 # Only initial check + } } - Context "When Write-ScoopCache fails after install" { + Context "When installation command throws exception" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { throw "Install command failed" } + } + It "Should return false" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $false + } + + It "Should log installation error with package name" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to install Scoop package 'git'*" -and $Verbosity -eq "Error" + } + } + } + + Context "When cache update fails after installation" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } - Mock Uninstall-ScoopPackage { return $true } Mock Invoke-Command { $global:LASTEXITCODE = 0 } Mock Write-ScoopCache { return $false } + } + + It "Should return false" { $result = Install-ScoopPackage -PackageName "git" $result | Should -Be $false } + + It "Should attempt cache update" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-ScoopCache -Times 1 + } } - Context "When installing with version, bucket, and global" { - It "Should pass correct arguments and return true" { + Context "When installation verification fails" { + BeforeEach { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } - $callCount = 0 - Mock Test-ScoopComponentInstalled -MockWith { - $callCount++ - if ($callCount -eq 1) { [InstalledState]::Installed } - else { [InstalledState]::Pass } + $script:callCount = 0 + Mock Test-ScoopComponentInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { [InstalledState]::NotInstalled } + else { throw "Verification failed" } } - Mock Uninstall-ScoopPackage { return $true } - Mock Invoke-Command { - param($ScriptBlock) - $global:LASTEXITCODE = 0 - # Optionally, check the arguments passed to scoop - return $null + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Write-ScoopCache { return $true } + } + + It "Should return false" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $false + } + + It "Should log verification error with package name" { + Install-ScoopPackage -PackageName "git" + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to verify installation of Scoop package 'git'*" -and $Verbosity -eq "Error" } + } + } + + Context "When using WhatIf parameter" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } Mock Write-ScoopCache { return $true } - $result = Install-ScoopPackage -PackageName "python" -Version "3.11.5" -Bucket "main" -Global + } + + It "Should not execute install command when WhatIf is specified" { + $result = Install-ScoopPackage -PackageName "git" -WhatIf + $result | Should -Be $true + Should -Invoke Invoke-Command -Times 0 -Exactly + } + + It "Should return true and log debug message when WhatIf is used" { + $result = Install-ScoopPackage -PackageName "git" -WhatIf + $result | Should -Be $true + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Skipping installation of Scoop package 'git' due to ShouldProcess*" -and $Verbosity -eq "Debug" + } -Times 1 -Exactly + } + + It "Should not call Write-ScoopCache when WhatIf is used" { + Install-ScoopPackage -PackageName "git" -WhatIf + Should -Invoke Write-ScoopCache -Times 0 -Exactly + } + + It "Should still check if package is already installed with WhatIf" { + Mock Test-ScoopComponentInstalled { return [InstalledState]::Pass } + $result = Install-ScoopPackage -PackageName "git" -WhatIf $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -Times 1 -Exactly + } + + It "Should handle reinstallation scenario with WhatIf" { + Mock Test-ScoopComponentInstalled { return [InstalledState]::Installed } + Mock Uninstall-ScoopPackage { return $true } + + $result = Install-ScoopPackage -PackageName "git" -WhatIf + $result | Should -Be $true + Should -Invoke Uninstall-ScoopPackage -ParameterFilter { + $PackageName -eq "git" -and $WhatIf -eq $true + } -Times 1 -Exactly + Should -Invoke Invoke-Command -Times 0 -Exactly + } + + It "Should work with all parameters and WhatIf" { + $result = Install-ScoopPackage -PackageName "python" -Version "3.11.5" -Bucket "main" -Global -WhatIf + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Package -eq $true -and $Name -eq "python" -and $Version -eq "3.11.5" -and $Global -eq $true + } -Times 1 -Exactly + Should -Invoke Invoke-Command -Times 0 -Exactly } } - Context "When an exception occurs" { - It "Should return false" { - Mock Test-ScoopInstalled { throw "Unexpected error" } + Context "ShouldProcess functionality" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Write-ScoopCache { return $true } + } + + It "Should have SupportsShouldProcess attribute" { + $function = Get-Command Install-ScoopPackage + $function.CmdletBinding | Should -Be $true + $function.Parameters.ContainsKey('WhatIf') | Should -Be $true + $function.Parameters.ContainsKey('Confirm') | Should -Be $true + } + + It "Should execute normally when ShouldProcess returns true" { + $result = Install-ScoopPackage -PackageName "git" -Confirm:$false + $result | Should -Be $false # Returns Test-ScoopComponentInstalled result (mocked as NotInstalled) + Should -Invoke Invoke-Command -Times 1 -Exactly + } + } + + Context "Parameter validation and edge cases" { + BeforeEach { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + $script:callCount = 0 + Mock Test-ScoopComponentInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { [InstalledState]::NotInstalled } + else { [InstalledState]::Pass } + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Write-ScoopCache { return $true } + } + + It "Should handle package names with special characters" { + $result = Install-ScoopPackage -PackageName "package-with-dashes" + $result | Should -Be ([InstalledState]::Pass) + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Name -eq "package-with-dashes" + } + } + + It "Should handle version parameter correctly when not specified" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be ([InstalledState]::Pass) + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Name -eq "git" + } + } + + It "Should handle bucket parameter correctly when not specified" { + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be ([InstalledState]::Pass) + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Name -eq "git" + } + } + + It "Should pass correct parameters to Test-ScoopComponentInstalled" { + Install-ScoopPackage -PackageName "test-package" -Version "1.0.0" -Global + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Package -eq $true -and $Name -eq "test-package" -and $Version -eq "1.0.0" -and $Global -eq $true + } + } + } + + Context "Integration test scenarios" { + It "Should handle complete successful installation flow" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "C:\Users\Test\scoop\shims\scoop" } + $script:callCount = 0 + Mock Test-ScoopComponentInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { [InstalledState]::NotInstalled } + else { [InstalledState]::Pass } + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Write-ScoopCache { return $true } + + $result = Install-ScoopPackage -PackageName "nodejs" -Version "18.17.0" -Bucket "main" -Global + + $result | Should -Be ([InstalledState]::Pass) + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Invoke Find-Scoop -Times 1 + Should -Invoke Test-ScoopComponentInstalled -Times 2 + Should -Invoke Invoke-Command -Times 1 + Should -Invoke Write-ScoopCache -Times 1 + Should -Not -Invoke Uninstall-ScoopPackage + } + + It "Should handle complete reinstallation flow" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + $script:callCount = 0 + Mock Test-ScoopComponentInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { [InstalledState]::Installed } + else { [InstalledState]::Pass } + } + Mock Uninstall-ScoopPackage { return $true } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Write-ScoopCache { return $true } + + $result = Install-ScoopPackage -PackageName "python" + + $result | Should -Be ([InstalledState]::Pass) + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Invoke Find-Scoop -Times 1 + Should -Invoke Test-ScoopComponentInstalled -Times 2 + Should -Invoke Uninstall-ScoopPackage -Times 1 + Should -Invoke Invoke-Command -Times 1 + Should -Invoke Write-ScoopCache -Times 1 + } + + It "Should handle complete failure scenario with error logging" { + Mock Test-ScoopInstalled { throw "Test failure" } + + $result = Install-ScoopPackage -PackageName "git" + + $result | Should -Be $false + Should -Invoke Write-StatusMessage -Times 2 -ParameterFilter { $Verbosity -eq "Error" } + Should -Not -Invoke Find-Scoop + Should -Not -Invoke Test-ScoopComponentInstalled + Should -Not -Invoke Invoke-Command + Should -Not -Invoke Write-ScoopCache + Should -Not -Invoke Uninstall-ScoopPackage + } + + It "Should handle early exit when scoop not installed" { + Mock Test-ScoopInstalled { return $false } + $result = Install-ScoopPackage -PackageName "git" + $result | Should -Be $false + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Not -Invoke Find-Scoop + Should -Not -Invoke Test-ScoopComponentInstalled + Should -Not -Invoke Invoke-Command + Should -Not -Invoke Write-ScoopCache + Should -Not -Invoke Uninstall-ScoopPackage } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.ps1 b/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.ps1 index 9d8a1c5..2c21dd5 100644 --- a/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.ps1 +++ b/DevSetup/Private/Providers/Scoop/Install-ScoopPackage.ps1 @@ -74,7 +74,7 @@ Package Management, Package Installation #> Function Install-ScoopPackage { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [string]$PackageName, @@ -95,69 +95,107 @@ Function Install-ScoopPackage { if(-Not (Test-ScoopInstalled)) { return $false } + } catch { + Write-StatusMessage "Scoop is not installed. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + try { $scoopCommand = Find-Scoop if (-not $scoopCommand) { return $false } + } catch { + Write-StatusMessage "Failed to find Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - $Params = @{ - Package = $true - Name = $PackageName - } + $Params = @{ + Package = $true + Name = $PackageName + } - if($PSBoundParameters.ContainsKey('Version') -and $Version) { - $Params.Version = $Version - } + if($PSBoundParameters.ContainsKey('Version') -and $Version) { + $Params.Version = $Version + } - if($Global) { - $Params.Global = $Global - } + if($Global) { + $Params.Global = $Global + } + try { [InstalledState]$packageState = Test-ScoopComponentInstalled @Params - if ($packageState -eq [InstalledState]::Pass) { - Write-Debug "Scoop package '$PackageName' is already installed with the specified version and global scope." - return $true - } - - if($packageState.HasFlag([InstalledState]::Installed)) { - Write-Debug "Scoop package '$PackageName' is installed but does not meet the global scope and/or version requirements. Reinstalling..." - Uninstall-ScoopPackage -PackageName $PackageName | Out-null - } + } catch { + Write-StatusMessage "Failed to check if Scoop package '$PackageName' is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - $fullPackageName = $PackageName - if ($PSBoundParameters.ContainsKey('Bucket')) { - $fullPackageName = "$Bucket/$PackageName" - } - - # Add version if specified - if ($PSBoundParameters.ContainsKey('Version')) { - $fullPackageName += "@$Version" - } - - # Build arguments array for installation - $installArgs = @("install", $fullPackageName) - - # Add global flag if specified - if ($Global) { - $installArgs += "--global" - } - - # Execute the install command with proper argument parsing - $command = { - & $scoopCommand @installArgs *> $null + if ($packageState -eq [InstalledState]::Pass) { + Write-StatusMessage "Scoop package '$PackageName' is already installed with the specified version and global scope." -Verbosity Debug + return $true + } + + if($packageState.HasFlag([InstalledState]::Installed)) { + Write-StatusMessage "Scoop package '$PackageName' is installed but does not meet the global scope and/or version requirements. Reinstalling..." -Verbosity Debug + try { + Uninstall-ScoopPackage -PackageName $PackageName -WhatIf:$PSCmdlet.WhatIf | Out-null + } catch { + Write-StatusMessage "Failed to uninstall existing Scoop package '$PackageName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false } + } - Invoke-Command -ScriptBlock $command | Out-Null - if ($LASTEXITCODE -ne 0) { + $fullPackageName = $PackageName + if ($PSBoundParameters.ContainsKey('Bucket')) { + $fullPackageName = "$Bucket/$PackageName" + } + + # Add version if specified + if ($PSBoundParameters.ContainsKey('Version')) { + $fullPackageName += "@$Version" + } + + # Build arguments array for installation + $installArgs = @("install", $fullPackageName) + + # Add global flag if specified + if ($Global) { + $installArgs += "--global" + } + + # Execute the install command with proper argument parsing + if ($PSCmdlet.ShouldProcess($PackageName, "Install Scoop Package")) { + try { + Invoke-Command -ScriptBlock { & $scoopCommand @installArgs } *> $null + } catch { + Write-StatusMessage "Failed to install Scoop package '$PackageName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } + } else { + Write-StatusMessage "Skipping installation of Scoop package '$PackageName' due to ShouldProcess" -Verbosity Debug + return $true + } + + if ($LASTEXITCODE -ne 0) { + return $false + } - if (-not (Write-ScoopCache)) { - return $false - } - return Test-ScoopComponentInstalled @Params + if (-not (Write-ScoopCache)) { + return $false + } + + $packageStatus = $false + try { + $packageStatus = Test-ScoopComponentInstalled @Params } catch { + Write-StatusMessage "Failed to verify installation of Scoop package '$PackageName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } + return $packageStatus } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentExport.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentExport.Tests.ps1 new file mode 100644 index 0000000..6431270 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentExport.Tests.ps1 @@ -0,0 +1,734 @@ +BeforeAll { + . $PSScriptRoot\Invoke-ScoopComponentExport.ps1 + . $PSScriptRoot\Test-ScoopInstalled.ps1 + . $PSScriptRoot\Find-Scoop.ps1 + . $PSScriptRoot\Get-ScoopPackagesAvailable.ps1 + . $PSScriptRoot\Get-ScoopComponentsInstalled.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Read-DevSetupEnvFile.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Update-DevSetupEnvFile.ps1 + Mock Write-StatusMessage { } + Mock Write-Host {} + Mock Write-Error {} +} + +Describe "Invoke-ScoopComponentExport" { + + BeforeEach { + # Common mock data for tests + $script:mockYamlData = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + packages = @() + buckets = @() + } + } + } + } + + $script:mockScoopPackagesAvailable = @{ + "git" = @{ Name = "git"; Version = "2.39.0.windows.2"; Source = "main" } + "nodejs" = @{ Name = "nodejs"; Version = "18.13.0"; Source = "main" } + "vscode" = @{ Name = "vscode"; Version = "1.74.2"; Source = "extras" } + } + + $script:mockScoopExportData = @{ + apps = @( + @{ Name = "git"; Version = "2.39.0.windows.2"; Info = "" } + @{ Name = "nodejs"; Version = "18.13.0"; Info = "Global install" } + @{ Name = "vscode"; Version = "1.74.2"; Info = "" } + ) + buckets = @( + @{ Name = "main"; Source = "https://github.com/ScoopInstaller/Main" } + @{ Name = "extras"; Source = "https://github.com/ScoopInstaller/Extras" } + ) + } + } + + Context "When Scoop is not installed" { + It "Should return false and warn about Scoop not being installed" { + Mock Test-ScoopInstalled { return $false } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Scoop is not installed. Cannot check for components." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Test-ScoopInstalled throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { throw "Critical error checking Scoop" } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Error checking Scoop installation: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When Find-Scoop returns null" { + It "Should return false and warn about failing to find Scoop command" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return $null } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -eq "Failed to find Scoop command. Cannot check for components." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Find-Scoop throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { throw "Error finding Scoop executable" } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Error finding Scoop command: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When Get-ScoopPackagesAvailable returns null" { + It "Should return true and warn about no packages found" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $null } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No Scoop packages found or unable to retrieve packages." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Get-ScoopPackagesAvailable throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { throw "Error getting packages" } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not get Scoop packages: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When Get-ScoopComponentsInstalled returns null" { + It "Should return true and warn about no components found" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { return $null } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No Scoop components found or unable to retrieve components." -and $Verbosity -eq "Warning" + } + } + } + + Context "When Get-ScoopComponentsInstalled throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { throw "Error getting components" } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not get Scoop components: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When ConvertFrom-Json fails with invalid JSON" { + It "Should return false and log JSON parsing error" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { throw "Simulated parsing error" } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not get Scoop components: *" -and $Verbosity -eq "Error" + } + } + } + + Context "When no packages are found after JSON parsing" { + It "Should return true and warn about no packages" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return @{ apps = @(); buckets = @() } + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No Scoop packages found." -and $Verbosity -eq "Warning" + } + } + } + + Context "When successfully processing packages and buckets" { + It "Should export packages and buckets to YAML configuration" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Scoop packages conversion completed!" + } + } + } + + Context "When main bucket should be skipped" { + It "Should skip the main bucket but process other buckets" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + # Should skip main bucket + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Skipping 'main' bucket (automatically installed with Scoop)" -and $Verbosity -eq "Debug" + } + } + } + + Context "When DryRun is specified" { + It "Should process data but not save to file" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" -DryRun + + $result | Should -Be $true + Assert-MockCalled Update-DevSetupEnvFile -ParameterFilter { $WhatIf -eq $true } + } + } + + Context "When packages have global installation info" { + It "Should correctly identify global installations" { + $mockGlobalExportData = @{ + apps = @( + @{ Name = "git"; Version = "2.39.0.windows.2"; Info = "Global install" } + @{ Name = "nodejs"; Version = "18.13.0"; Info = "" } + ) + buckets = @() + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockGlobalExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + # Should process global installation correctly + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "*git* (version: 2.39.0.windows.2, bucket: main, global: True)" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "*nodejs* (version: 18.13.0, bucket: main, global: False)" -and $Verbosity -eq "Debug" + } + } + } + + Context "When existing packages need updates" { + It "Should update existing packages with new versions or properties" { + $mockYamlWithExisting = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + packages = @( + @{ name = "git"; version = "2.38.0.windows.1"; bucket = "main"; global = $false } + ) + buckets = @() + } + } + } + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlWithExisting } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + # Should indicate package update + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "- Updating package: git*" + } + } + } + + Context "When existing buckets need updates" { + It "Should update existing buckets with new sources" { + $mockYamlWithExistingBucket = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + packages = @() + buckets = @( + @{ name = "extras"; source = "https://old-source.com/Extras" } + ) + } + } + } + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlWithExistingBucket } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + # Should indicate bucket update + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "- Updating bucket: extras*" + } + } + } + + Context "When Update-DevSetupEnvFile fails" { + It "Should return false and log error" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { throw "Failed to save file" } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Failed to save configuration to test.yaml*" -and $Verbosity -eq "Error" + } + } + } + + Context "When export data has no buckets property" { + It "Should handle missing buckets property gracefully" { + $mockExportDataNoBuckets = @{ + apps = @( + @{ Name = "git"; Version = "2.39.0.windows.2"; Info = "" } + ) + # No buckets property at all + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockExportDataNoBuckets + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No buckets found in scoop export JSON" -and $Verbosity -eq "Verbose" + } + } + } + + Context "When export data has empty buckets array" { + It "Should handle empty buckets array gracefully" { + $mockExportDataEmptyBuckets = @{ + apps = @( + @{ Name = "git"; Version = "2.39.0.windows.2"; Info = "" } + ) + buckets = @() # Empty array + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockExportDataEmptyBuckets + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No buckets found in scoop export JSON" -and $Verbosity -eq "Verbose" + } + } + } + + Context "When export data has no apps property" { + It "Should handle missing apps property gracefully" { + $mockExportDataNoApps = @{ + # No apps property at all + buckets = @( + @{ Name = "extras"; Source = "https://github.com/ScoopInstaller/Extras" } + ) + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockExportDataNoApps + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No apps found in scoop export JSON" -and $Verbosity -eq "Verbose" + } + } + } + + Context "When export data has empty apps array" { + It "Should handle empty apps array gracefully" { + $mockExportDataEmptyApps = @{ + apps = @() # Empty array + buckets = @( + @{ Name = "extras"; Source = "https://github.com/ScoopInstaller/Extras" } + ) + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockExportDataEmptyApps + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "No apps found in scoop export JSON" -and $Verbosity -eq "Verbose" + } + } + } + + Context "When existing bucket has no source property" { + It "Should skip updating bucket when existing source is null" { + $mockYamlWithBucketNoSource = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + packages = @() + buckets = @( + @{ name = "extras" } # No source property + ) + } + } + } + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlWithBucketNoSource } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "- Skipping bucket (No Change): extras*" + } + } + } + + Context "When existing package has no version, global, or bucket properties" { + It "Should skip updating package when existing properties are null" { + $mockYamlWithPackageNoProps = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + packages = @( + @{ name = "git" } # No version, global, or bucket properties + ) + buckets = @() + } + } + } + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlWithPackageNoProps } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "- Skipping package (No Change): git*" + } + } + } + + Context "When existing package needs only global property update" { + It "Should update package when only global property changes" { + $mockYamlWithGlobalDiff = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + packages = @( + @{ name = "git"; version = "2.39.0.windows.2"; bucket = "main"; global = $true } + ) + buckets = @() + } + } + } + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlWithGlobalDiff } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "- Updating package: git*" + } + } + } + + Context "When existing package needs only bucket property update" { + It "Should update package when only bucket property changes" { + $mockYamlWithBucketDiff = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + packages = @( + @{ name = "git"; version = "2.39.0.windows.2"; bucket = "extras"; global = $false } + ) + buckets = @() + } + } + } + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlWithBucketDiff } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "- Updating package: git*" + } + } + } + + Context "When package has missing source in available packages list" { + It "Should handle missing package source gracefully" { + $mockScoopPackagesNoSource = @{ + "git" = @{ Name = "git"; Version = "2.39.0.windows.2" } # No Source property + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesNoSource } + Mock Get-ScoopComponentsInstalled { + $singleAppData = @{ + apps = @(@{ Name = "git"; Version = "2.39.0.windows.2"; Info = "" }) + buckets = @() + } + return $singleAppData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + # Should still process the package even without source + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Found package: git*" + } + } + } + + Context "When bucket processing encounters debug logging" { + It "Should log bucket processing details at debug level" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Found bucket: extras*" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Found * Scoop packages and * buckets" -and $Verbosity -eq "Debug" + } + } + } + + Context "When save operation logging is triggered" { + It "Should log configuration save operations" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $mockScoopPackagesAvailable } + Mock Get-ScoopComponentsInstalled { + return $mockScoopExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $result = Invoke-ScoopComponentExport -Config "test.yaml" + + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "*Saving configuration to:*" -and $Verbosity -eq "Debug" + } + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Configuration saved successfully!" -and $Verbosity -eq "Debug" + } + } + } + + Context "Performance test with large dataset" { + It "Should handle large numbers of packages and buckets efficiently" { + # Generate large mock datasets + $largeMockPackages = @{} + $largeMockApps = @() + for ($i = 1; $i -le 500; $i++) { + $largeMockPackages["package$i"] = @{ Name = "package$i"; Version = "1.0.$i"; Source = "main" } + $largeMockApps += @{ Name = "package$i"; Version = "1.0.$i"; Info = "" } + } + + $largeMockExportData = @{ + apps = $largeMockApps + buckets = @( + @{ Name = "extras"; Source = "https://github.com/ScoopInstaller/Extras" } + ) + } + + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Get-ScoopPackagesAvailable { return $largeMockPackages } + Mock Get-ScoopComponentsInstalled { + return $largeMockExportData + } + Mock Read-DevSetupEnvFile { return $mockYamlData } + Mock Update-DevSetupEnvFile { } + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $result = Invoke-ScoopComponentExport -Config "test.yaml" + $stopwatch.Stop() + + $result | Should -Be $true + # Should complete in reasonable time (less than 30 seconds) + $stopwatch.ElapsedMilliseconds | Should -BeLessThan 30000 + } + } +} diff --git a/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentExport.ps1 b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentExport.ps1 new file mode 100644 index 0000000..839cef8 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentExport.ps1 @@ -0,0 +1,259 @@ +๏ปฟ<# +.SYNOPSIS + Exports installed Scoop packages and buckets to a YAML configuration file. + +.DESCRIPTION + This function scans the system for installed Scoop packages and buckets, then exports them to a YAML + configuration file in DevSetup format. It uses 'scoop export' to retrieve comprehensive package information + including versions, buckets, and global installation status. The function can update existing configuration + files by merging new packages with existing ones, or create new configurations from scratch. + +.PARAMETER Config + The path to the YAML configuration file to read from and write to. + This parameter is mandatory and specifies both the input and output file unless OutFile is specified. + +.PARAMETER OutFile + The path to save the updated YAML configuration. + Optional parameter that allows saving to a different file than the input Config file. + +.PARAMETER DryRun + Switch parameter that prevents writing to files and displays the resulting configuration to the console. + Useful for previewing changes before committing them to a file. + +.OUTPUTS + [System.Boolean] + Returns $true if the export completes successfully or if Scoop is not installed (skipped). + Returns $false if there are errors during the export process. + +.EXAMPLE + Invoke-ScoopComponentExport -Config "environment.yaml" + + Exports installed Scoop packages to the existing environment.yaml configuration file. + +.EXAMPLE + Invoke-ScoopComponentExport -Config "current.yaml" -OutFile "backup.yaml" + + Reads from current.yaml and saves the updated configuration with installed packages to backup.yaml. + +.EXAMPLE + Invoke-ScoopComponentExport -Config "dev-env.yaml" -DryRun + + Shows what the configuration would look like without actually saving to file. + +.NOTES + - Requires Scoop to be installed on the system (gracefully skips if not found) + - Uses 'scoop export' command to retrieve package and bucket information in JSON format + - Handles both local and global package installations using Info field detection + - Automatically skips the 'main' bucket as it's installed by default with Scoop + - Merges with existing YAML configuration, preserving other sections and structure + - Supports both simple string format and complex object format for packages and buckets + - Updates existing packages/buckets when versions or sources have changed + - Tracks global installation status and bucket information for each package + - Provides detailed console output with color-coded status messages for all operations + - Creates the devsetup.dependencies.scoop structure if it doesn't exist + - Processes buckets before packages to ensure proper dependency order + - Converts string entries to hashtable format when additional properties are needed + - Preserves existing package properties while updating changed values + - Includes comprehensive error handling for JSON parsing and file operations + - Returns $true even when no packages are found (successful empty result) + +.LINK + +.COMPONENT + DevSetup.Providers.Scoop + +.FUNCTIONALITY + Configuration Export, Package Discovery, YAML Generation +#> + +Function Invoke-ScoopComponentExport { + Param( + [Parameter(Mandatory = $true)] + [string]$Config, + [switch]$DryRun + ) + + try { + # Check if Scoop is installed + if(-Not (Test-ScoopInstalled)) { + Write-StatusMessage "Scoop is not installed. Cannot check for components." -Verbosity Warning + return $false + } + } catch { + Write-StatusMessage "Error checking Scoop installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + $scoopCommand = Find-Scoop + if (-not $scoopCommand) { + Write-StatusMessage "Failed to find Scoop command. Cannot check for components." -Verbosity Warning + return $false + } + } catch { + Write-StatusMessage "Error finding Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + # Get list of installed Scoop packages + Write-StatusMessage "- Getting list of installed Scoop packages..." + + # Get all packages (both local and global) using scoop export + $scoopListLocal = $null + + try { + $scoopPackageList = Get-ScoopPackagesAvailable + if ($null -eq $scoopPackageList) { + Write-StatusMessage "No Scoop packages found or unable to retrieve packages." -Verbosity Warning + return $true + } + } catch { + Write-StatusMessage "Could not get Scoop packages: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + $scoopListLocal = Get-ScoopComponentsInstalled + if ($null -eq $scoopListLocal) { + Write-StatusMessage "No Scoop components found or unable to retrieve components." -Verbosity Warning + return $true + } + } catch { + Write-StatusMessage "Could not get Scoop components: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + $scoopPackages = @() + $scoopBuckets = @() + + # Parse buckets from the JSON structure + if ($scoopListLocal.buckets -and $scoopListLocal.buckets.Count -gt 0) { + foreach ($bucket in $scoopListLocal.buckets) { + # Skip the 'main' bucket as it's automatically installed with Scoop + if ($bucket.Name -eq "main") { + Write-StatusMessage "Skipping 'main' bucket (automatically installed with Scoop)" -Verbosity Debug + continue + } + $scoopBuckets += @{ + name = $bucket.Name + source = $bucket.Source + } + Write-StatusMessage "Found bucket: $($bucket.Name) (source: $($bucket.Source))" -Verbosity Debug + } + } else { + Write-StatusMessage "No buckets found in scoop export JSON" -Verbosity Verbose + } + + # Parse apps from the JSON structure + if ($scoopListLocal.apps -and $scoopListLocal.apps.Count -gt 0) { + foreach ($app in $scoopListLocal.apps) { + $scoopPackages += @{ + name = $app.Name + version = $app.Version + global = ($app.Info -eq "Global install") + bucket = $scoopPackageList[$app.Name].Source + } + Write-StatusMessage "Found package: $($app.Name) (version: $($app.Version), bucket: $($scoopPackageList[$app.Name].Source), global: $($app.Info -eq 'Global install'))" -Verbosity Debug + } + } else { + Write-StatusMessage "No apps found in scoop export JSON" -Verbosity Verbose + } + + if ($scoopPackages.Count -eq 0) { + Write-StatusMessage "No Scoop packages found." -Verbosity Warning + return $true + } + + Write-StatusMessage "Found $($scoopPackages.Count) Scoop packages and $($scoopBuckets.Count) buckets" -Verbosity Debug + + $YamlData = Read-DevSetupEnvFile -Config $Config + + foreach ($bucket in $scoopBuckets) { + $existingBucket = $YamlData.devsetup.dependencies.scoop.buckets | Where-Object { + ($_.name -eq $bucket.name) + } + + if (-not $existingBucket) { + Write-StatusMessage "- Adding bucket: $($bucket.name) ($($bucket.source))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + $YamlData.devsetup.dependencies.scoop.buckets += @{ + name = $bucket.name + source = $bucket.source + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + $existingSource = $existingBucket.source + if ($existingSource -and $existingSource -ne $bucket.source) { + Write-StatusMessage "- Updating bucket: $($bucket.name) ($existingSource -> $($bucket.source))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + $index = $YamlData.devsetup.dependencies.scoop.buckets.IndexOf($existingBucket) + $YamlData.devsetup.dependencies.scoop.buckets[$index].source = $bucket.source + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + Write-StatusMessage "- Skipping bucket (No Change): $($bucket.name) ($($bucket.source))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + Write-StatusMessage "[OK]" -ForegroundColor Green + } + } + } + + # Add packages to YAML data + foreach ($package in $scoopPackages) { + # Check if package already exists + $existingPackage = $YamlData.devsetup.dependencies.scoop.packages | Where-Object { + ($_.name -eq $package.name) + } + + if (-not $existingPackage) { + Write-StatusMessage "- Adding package: $($package.name) ($($package.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + + # Create package object with all relevant properties + $packageObj = @{ + name = $package.name + version = $package.version + bucket = $package.bucket + global = $package.global + } + + $YamlData.devsetup.dependencies.scoop.packages += $packageObj + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + $existingVersion = $existingPackage.version + $existingGlobal = $existingPackage.global + $existingBucket = $existingPackage.bucket + + if (($existingVersion -and $existingVersion -ne $package.version) -or + ($existingGlobal -and $existingGlobal -ne $package.global) -or + ($existingBucket -and $existingBucket -ne $package.bucket)) { + Write-StatusMessage "- Updating package: $($package.name) ($existingVersion -> $($package.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + + $index = $YamlData.devsetup.dependencies.scoop.packages.IndexOf($existingPackage) + $YamlData.devsetup.dependencies.scoop.packages[$index] = @{ + name = $package.name + version = $package.version + bucket = $package.bucket + global = $package.global + } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + Write-StatusMessage "- Skipping package (No Change): $($package.name) ($($package.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + Write-StatusMessage "[OK]" -ForegroundColor Green + } + } + } + + try { + Write-StatusMessage "`nSaving configuration to: $Config" -Verbosity Debug + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun + Write-StatusMessage "Configuration saved successfully!" -Verbosity Debug + } + catch { + Write-StatusMessage "Failed to save configuration to $Config`: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "Scoop packages conversion completed!" -ForegroundColor Green + return $true +} diff --git a/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentInstall.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentInstall.Tests.ps1 new file mode 100644 index 0000000..ba02084 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentInstall.Tests.ps1 @@ -0,0 +1,415 @@ +BeforeAll { + . $PSScriptRoot\Invoke-ScoopComponentInstall.ps1 + . $PSScriptRoot\Test-ScoopInstalled.ps1 + . $PSScriptRoot\Write-ScoopCache.ps1 + . $PSScriptRoot\Install-ScoopBucket.ps1 + . $PSScriptRoot\Install-ScoopPackage.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + Mock Write-StatusMessage { } + Mock Write-Host {} + Mock Write-Error {} +} + +Describe "Invoke-ScoopComponentInstall" { + + BeforeEach { + $global:LASTEXITCODE = 0 + # Mock data for testing + $script:mockYamlDataEmpty = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @() + packages = @() + } + } + } + } + + $script:mockYamlDataWithBuckets = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @( + [PSCustomObject]@{ name = "extras" }, + [PSCustomObject]@{ name = "versions"; source = "https://github.com/ScoopInstaller/Versions" } + ) + packages = @() + } + } + } + } + + $script:mockYamlDataWithPackages = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @() + packages = @( + [PSCustomObject]@{ name = "git" }, + [PSCustomObject]@{ name = "nodejs"; version = "18.17.0" }, + [PSCustomObject]@{ name = "7zip"; global = $true }, + [PSCustomObject]@{ name = "firefox"; bucket = "extras"; global = $false } + ) + } + } + } + } + + $script:mockYamlDataFull = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @( + [PSCustomObject]@{ name = "extras" }, + [PSCustomObject]@{ name = "custom"; source = "https://github.com/user/custom-bucket" } + ) + packages = @( + [PSCustomObject]@{ name = "git" }, + [PSCustomObject]@{ name = "nodejs"; version = "18.17.0"; bucket = "main"; global = $false } + ) + } + } + } + } + } + + Context "When Scoop is not installed" { + It "Should return false and warn about Scoop not being installed" { + Mock Test-ScoopInstalled { return $false } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataFull + + $result | Should -Be $false + Assert-MockCalled Test-ScoopInstalled -Times 1 -Exactly -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Scoop is not installed. Cannot check for components." -and $Verbosity -eq "Warning" + } -Times 1 -Exactly -Scope It + } + } + + Context "When Test-ScoopInstalled throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { throw "Scoop verification failed" } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataFull + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not verify Scoop installation: *" -and $Verbosity -eq "Error" + } -Times 1 -Exactly -Scope It + } + } + + Context "When Write-ScoopCache fails" { + It "Should return false and log cache update failure" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $false } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataFull + + $result | Should -Be $false + Assert-MockCalled Test-ScoopInstalled -Times 1 -Exactly -Scope It + Assert-MockCalled Write-ScoopCache -Times 1 -Exactly -Scope It + } + } + + Context "When Write-ScoopCache throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { throw "Cache update failed" } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataFull + + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Could not update Scoop cache: *" -and $Verbosity -eq "Error" + } -Times 1 -Exactly -Scope It + } + } + + Context "When no buckets or packages are configured" { + It "Should return true and process empty configuration successfully" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataEmpty + + $result | Should -Be $true + Assert-MockCalled Test-ScoopInstalled -Times 1 -Exactly -Scope It + Assert-MockCalled Write-ScoopCache -Times 1 -Exactly -Scope It + } + } + + Context "When processing buckets with valid configurations" { + It "Should install all buckets successfully" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopBucket { return $true } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $true + Assert-MockCalled Install-ScoopBucket -Times 2 -Exactly -Scope It + Assert-MockCalled Install-ScoopBucket -ParameterFilter { + $Name -eq "extras" -and $WhatIf -eq $false + } -Times 1 -Exactly -Scope It + Assert-MockCalled Install-ScoopBucket -ParameterFilter { + $Name -eq "versions" -and $Source -eq "https://github.com/ScoopInstaller/Versions" -and $WhatIf -eq $false + } -Times 1 -Exactly -Scope It + } + } + + Context "When processing buckets with DryRun enabled" { + It "Should pass WhatIf parameter to Install-ScoopBucket" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopBucket { return $true } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithBuckets -DryRun + + $result | Should -Be $true + Assert-MockCalled Install-ScoopBucket -ParameterFilter { + $WhatIf -eq $true + } -Times 2 -Exactly -Scope It + } + } + + Context "When bucket installation fails" { + It "Should continue processing and still return true" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopBucket { + param($Name) + if ($Name -eq "extras") { return $false } else { return $true } + } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $true + Assert-MockCalled Install-ScoopBucket -Times 2 -Exactly -Scope It + } + } + + Context "When bucket has missing or invalid name" { + It "Should skip bucket and log warning" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopBucket { return $true } + + $yamlDataInvalidBucket = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @( + [PSCustomObject]@{ name = "" }, # Empty name + [PSCustomObject]@{ source = "https://example.com" }, # Missing name + [PSCustomObject]@{ name = "valid" } # Valid bucket + ) + packages = @() + } + } + } + } + + $result = Invoke-ScoopComponentInstall -YamlData $yamlDataInvalidBucket + + $result | Should -Be $true + Assert-MockCalled Install-ScoopBucket -Times 1 -Exactly -Scope It # Only valid bucket processed + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Skipping bucket entry, No name specified" -and $Verbosity -eq "Warning" + } -Times 2 -Exactly -Scope It # Two invalid buckets skipped + } + } + + Context "When processing packages with valid configurations" { + It "Should install all packages successfully with correct parameters" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true + Assert-MockCalled Install-ScoopPackage -Times 4 -Exactly -Scope It + + # Test specific package configurations + Assert-MockCalled Install-ScoopPackage -ParameterFilter { + $PackageName -eq "git" -and $WhatIf -eq $false -and $Global -eq $false + } -Times 1 -Exactly -Scope It + + Assert-MockCalled Install-ScoopPackage -ParameterFilter { + $PackageName -eq "nodejs" -and $Version -eq "18.17.0" -and $WhatIf -eq $false -and $Global -eq $false + } -Times 1 -Exactly -Scope It + + Assert-MockCalled Install-ScoopPackage -ParameterFilter { + $PackageName -eq "7zip" -and $Global -eq $true -and $WhatIf -eq $false + } -Times 1 -Exactly -Scope It + + Assert-MockCalled Install-ScoopPackage -ParameterFilter { + $PackageName -eq "firefox" -and $Bucket -eq "extras" -and $Global -eq $false -and $WhatIf -eq $false + } -Times 1 -Exactly -Scope It + } + } + + Context "When processing packages with DryRun enabled" { + It "Should pass WhatIf parameter to Install-ScoopPackage" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithPackages -DryRun + + $result | Should -Be $true + Assert-MockCalled Install-ScoopPackage -ParameterFilter { + $WhatIf -eq $true + } -Times 4 -Exactly -Scope It + } + } + + Context "When package installation fails" { + It "Should continue processing and still return true" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopPackage { + param($PackageName) + if ($PackageName -eq "git") { return $false } else { return $true } + } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true + Assert-MockCalled Install-ScoopPackage -Times 4 -Exactly -Scope It + } + } + + Context "When package installation fails due to LASTEXITCODE" { + It "Should detect failure and log accordingly" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopPackage { + $global:LASTEXITCODE = 1 + return $true # Return true but set exit code to indicate failure + } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true # Function continues despite failures + Assert-MockCalled Install-ScoopPackage -Times 4 -Exactly -Scope It + } + } + + Context "When package has missing or invalid name" { + It "Should skip package and log warning" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopPackage { return $true } + + $yamlDataInvalidPackage = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @() + packages = @( + [PSCustomObject]@{ name = "" }, # Empty name + [PSCustomObject]@{ version = "1.0.0" }, # Missing name + [PSCustomObject]@{ name = "valid" } # Valid package + ) + } + } + } + } + + $result = Invoke-ScoopComponentInstall -YamlData $yamlDataInvalidPackage + + $result | Should -Be $true + Assert-MockCalled Install-ScoopPackage -Times 1 -Exactly -Scope It # Only valid package processed + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Skipping package entry, No name specified" -and $Verbosity -eq "Warning" + } -Times 2 -Exactly -Scope It # Two invalid packages skipped + } + } + + Context "When Install-ScoopPackage throws an exception" { + It "Should catch exception, log error, and continue processing" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopPackage { + param($PackageName) + if ($PackageName -eq "git") { + throw "Package installation failed" + } else { + return $true + } + } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true + Assert-MockCalled Install-ScoopPackage -Times 4 -Exactly -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -like "Failed to install Scoop package 'git': *" -and $Verbosity -eq "Error" + } -Times 1 -Exactly -Scope It + } + } + + Context "When processing both buckets and packages" { + It "Should process buckets first, then packages" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Install-ScoopBucket { return $true } + Mock Install-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataFull + + $result | Should -Be $true + Assert-MockCalled Install-ScoopBucket -Times 2 -Exactly -Scope It + Assert-MockCalled Install-ScoopPackage -Times 2 -Exactly -Scope It + } + } + + Context "When processing large configuration with mixed success/failure" { + It "Should handle mixed results and return true" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + + $bucketCallCount = 0 + Mock Install-ScoopBucket { + $bucketCallCount++ + return ($bucketCallCount % 2 -eq 1) # Alternate success/failure + } + + $packageCallCount = 0 + Mock Install-ScoopPackage { + $packageCallCount++ + if ($packageCallCount -eq 2) { throw "Random failure" } + return ($packageCallCount % 3 -ne 0) # Various success patterns + } + + $result = Invoke-ScoopComponentInstall -YamlData $script:mockYamlDataFull + + $result | Should -Be $true # Should succeed overall despite individual failures + Assert-MockCalled Install-ScoopBucket -Times 2 -Exactly -Scope It + Assert-MockCalled Install-ScoopPackage -Times 2 -Exactly -Scope It + } + } + + Context "When YAML data structure is missing required properties" { + It "Should handle missing scoop section gracefully" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + + $yamlDataMissingScoop = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + # Missing scoop section + } + } + } + + $result = Invoke-ScoopComponentInstall -YamlData $yamlDataMissingScoop + + $result | Should -Be $true # Should handle gracefully + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentInstall.ps1 b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentInstall.ps1 new file mode 100644 index 0000000..1534009 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentInstall.ps1 @@ -0,0 +1,249 @@ +๏ปฟ<# +.SYNOPSIS + Installs Scoop buckets and packages from YAML configuration data. + +.DESCRIPTION + This function processes YAML configuration data to install Scoop buckets and packages in sequence. + It validates Scoop installation, updates the cache before proceeding, and processes buckets before + packages to ensure bucket availability. The function supports object formats for buckets and packages, + allowing for detailed configuration including versions, custom sources, and global installation scope. + Progress is tracked and reported for both buckets and packages using color-coded status messages. + +.PARAMETER YamlData + The YAML configuration data containing Scoop bucket and package definitions. + This parameter is mandatory and must be a PSCustomObject with the structure: + devsetup.dependencies.scoop.buckets and/or devsetup.dependencies.scoop.packages + +.OUTPUTS + [System.Boolean] + Returns $false if Scoop is not installed, cannot be found, configuration is invalid, or cache update fails. + Returns $true if installation completes successfully (even if individual items fail). + +.EXAMPLE + $yamlData = Get-Content "config.yaml" | ConvertFrom-Yaml + Invoke-ScoopComponentInstall -YamlData $yamlData + + Installs Scoop buckets and packages from a YAML configuration file. + +.EXAMPLE + $yamlData = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + buckets = @( + @{ + name = "extras" + source = "https://github.com/ScoopInstaller/Extras" + }, + @{ + name = "custom-bucket" + source = "https://github.com/user/scoop-bucket" + } + ) + packages = @( + @{ + name = "git" + bucket = "main" + }, + @{ + name = "nodejs" + version = "18.17.0" + bucket = "main" + }, + @{ + name = "7zip" + bucket = "main" + global = $true + }, + @{ + name = "firefox" + bucket = "extras" + } + ) + } + } + } + } + Invoke-ScoopComponentInstall -YamlData $yamlData + + Demonstrates the PSCustomObject structure and installs the configured components. + +.EXAMPLE + if (Invoke-ScoopComponentInstall -YamlData $config) { + Write-Host "Scoop components installation completed" + } else { + Write-Host "Scoop components installation failed" + } + + Shows checking the return value to verify installation completion. + +.NOTES + - Requires Scoop to be installed on the system using Test-ScoopInstalled + - Returns $false immediately if Scoop is not installed or cannot be found + - Returns $false if YAML configuration structure is invalid or missing scoop section + - Updates Scoop cache using Write-ScoopCache before installation begins + - Returns $false if cache update fails to ensure accurate installation state + - Processes buckets before packages to ensure bucket availability for package installations + - Gracefully handles missing buckets or packages sections in configuration + - All bucket entries must be hashtables/objects with 'name' and 'source' fields: + * @{ name = "bucketname"; source = "https://github.com/user/scoop-bucket" } + - All package entries must be hashtables/objects with 'name' and 'bucket' fields: + * @{ name = "packagename"; bucket = "main"; version = "1.0.0"; global = $true } + - Validates component names and skips entries with missing names + - Uses Install-ScoopBucket and Install-ScoopPackage functions for actual installation + - Provides detailed progress reporting with component counts and property information + - Uses color-coded console output: Cyan for headers, Gray for items, Green/Red for status + - Displays formatted component information including version, bucket, and global flags + - Continues processing remaining components even if individual installations fail + - Returns $true for overall success even with individual component failures + - Includes comprehensive try-catch error handling with descriptive error messages + - Tracks and reports separate counts for buckets and packages processed + +.LINK + +.COMPONENT + DevSetup.Scoop + +.FUNCTIONALITY + Bulk Installation, Configuration Processing, Package Management +#> +Function Invoke-ScoopComponentInstall { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [PSCustomObject]$YamlData, + [switch]$DryRun + ) + + try { + if(-Not (Test-ScoopInstalled)) { + Write-StatusMessage "Scoop is not installed. Cannot check for components." -Verbosity "Warning" + return $false + } + } catch { + Write-StatusMessage "Could not verify Scoop installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + if (-not (Write-ScoopCache)) { + Write-Error "Failed to write Scoop cache file: $CacheFilePath" + return $false + } + } catch { + Write-StatusMessage "Could not update Scoop cache: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + $bucketCount = 0 + Write-StatusMessage "- Installing Scoop buckets from configuration:" -ForegroundColor Cyan + # Handle buckets first if they exist in configuration + $buckets = $YamlData.devsetup.dependencies.scoop.buckets + if ($buckets.Count -gt 0) { + foreach ($bucket in $buckets) { + if (-not $bucket -or [string]::IsNullOrEmpty($bucket.name)) { + Write-StatusMessage "- Skipping bucket entry, No name specified" -Verbosity "Warning" -Indent 2 -Width 112 + continue + } + + # Handle both string format and object format + $bucketName = $bucket.name + $bucketSource = if ($bucket.source) { $bucket.source } else { $null } + + $installParams = @{ + Name = $bucketName + WhatIf = $DryRun + } + + if ($bucketSource) { + $installParams.Source = $bucketSource + } + + # Use Install-ScoopBucket function to handle bucket installation + if ($bucketName -and $bucketSource) { + Write-StatusMessage "- Adding Scoop bucket: $bucketName (source: $bucketSource)" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + } else { + Write-StatusMessage "- Adding Scoop bucket: $bucketName" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + } + + $installationStatus = Install-ScoopBucket @installParams + + if (-not $installationStatus) { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + } else { + $bucketCount++ + Write-StatusMessage "[OK]" -ForegroundColor Green + } + } + } + + Write-StatusMessage "- Scoop buckets installation completed! Processed $bucketCount buckets.`n" -ForegroundColor Green + + $packageCount = 0 + Write-StatusMessage "- Installing Scoop packages from configuration:" -ForegroundColor Cyan + $scoopPackages = $YamlData.devsetup.dependencies.scoop.packages + + if ($scoopPackages.Count -gt 0) { + # Install packages + foreach ($package in $scoopPackages) { + if (-not $package -or [string]::IsNullOrEmpty($package.name)) { + Write-StatusMessage "- Skipping package entry, No name specified" -Verbosity "Warning" -Indent 2 -Width 112 + continue + } + + # Use Install-ScoopPackage function to handle the installation + $displayName = $package.name + $installParams = @{ + PackageName = $package.name + WhatIf = $DryRun + } + + $versionDisplay = "" + if ($package.version) { + $versionDisplay = "version: $($package.version)" + $installParams.Version = $package.version + } + + $bucketDisplay = "" + if ($package.bucket) { + $bucketDisplay = "bucket: '$($package.bucket)'" + $installParams.Bucket = $package.bucket + } + + $globalDisplay = "" + if ($package.global -eq $true) { + $globalDisplay = "global: true" + $installParams.Global = $true + } else { + $installParams.Global = $false + } + + if($versionDisplay -or $bucketDisplay -or $globalDisplay) { + $parts = @($versionDisplay, $bucketDisplay, $globalDisplay) | Where-Object { $_ } + $displayName += " (" + ($parts -join ", ") + ")" + } + Write-StatusMessage "- Installing Scoop package: $displayName" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewLine + + try { + $result = Install-ScoopPackage @installParams + + if ($LASTEXITCODE -ne 0 -or -not $result) { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + } else { + Write-StatusMessage "[OK]" -ForegroundColor Green + $packageCount++ + } + } catch { + Write-StatusMessage "Failed to install Scoop package '$($package.name)': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + continue + } + } + } + + Write-StatusMessage "- Scoop packages installation completed! Processed $packageCount packages.`n" -ForegroundColor Green + + return $true +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentUninstall.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentUninstall.Tests.ps1 new file mode 100644 index 0000000..a8ad238 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentUninstall.Tests.ps1 @@ -0,0 +1,428 @@ +BeforeAll { + . $PSScriptRoot\Invoke-ScoopComponentUninstall.ps1 + . $PSScriptRoot\Test-ScoopInstalled.ps1 + . $PSScriptRoot\Write-ScoopCache.ps1 + . $PSScriptRoot\Uninstall-ScoopBucket.ps1 + . $PSScriptRoot\Uninstall-ScoopPackage.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + Mock Write-StatusMessage { } +} + +Describe "Invoke-ScoopComponentUninstall" { + + BeforeEach { + $global:LASTEXITCODE = 0 + + # Mock data matching Assert-DevSetupEnvValid structure requirements + $script:mockYamlDataEmpty = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @() + packages = @() + } + } + } + } + + $script:mockYamlDataWithBuckets = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @( + [PSCustomObject]@{ name = "extras"; source = "https://github.com/ScoopInstaller/Extras.git" }, + [PSCustomObject]@{ name = "versions"; source = "https://github.com/ScoopInstaller/Versions.git" } + ) + packages = @() + } + } + } + } + + $script:mockYamlDataWithPackages = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @() + packages = @( + [PSCustomObject]@{ name = "git"; bucket = "main" }, + [PSCustomObject]@{ name = "nodejs"; version = "18.17.0"; bucket = "main" }, + [PSCustomObject]@{ name = "7zip"; global = $true; bucket = "extras" } + ) + } + } + } + } + + $script:mockYamlDataMixed = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @( + [PSCustomObject]@{ name = "extras"; source = "https://github.com/ScoopInstaller/Extras.git" } + ) + packages = @( + [PSCustomObject]@{ name = "git"; bucket = "main" }, + [PSCustomObject]@{ name = "nodejs"; version = "18.17.0"; bucket = "main"; global = $true } + ) + } + } + } + } + } + + Context "When Scoop is not installed" { + It "Should return false and display warning message" { + Mock Test-ScoopInstalled { return $false } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $false + Should -Invoke Test-ScoopInstalled -Times 1 -Exactly + Should -Invoke Write-StatusMessage -Times 1 -Exactly -ParameterFilter { + $Message -like "*Scoop is not installed*" -and $Verbosity -eq "Warning" + } + } + } + + Context "When Scoop configuration has empty arrays" { + It "Should return true and not attempt any uninstalls" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $true } + Mock Uninstall-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataEmpty + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -Times 0 -Exactly + Should -Invoke Uninstall-ScoopPackage -Times 0 -Exactly + } + } + + Context "When Write-ScoopCache fails" { + It "Should return false and display error message" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $false } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $false + Should -Invoke Write-StatusMessage -Times 1 -Exactly -ParameterFilter { + $Message -like "*Failed to write Scoop cache file*" -and $Verbosity -eq "Error" + } + } + } + + Context "When only buckets are present and all uninstalls succeed" { + It "Should return true and uninstall all buckets" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $true } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -Times 2 -Exactly + } + } + + Context "When only packages are present and all uninstalls succeed" { + It "Should return true and uninstall all packages" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopPackage -Times 3 -Exactly + } + } + + Context "When buckets and packages are present" { + It "Should return true and uninstall both buckets and packages" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $true } + Mock Uninstall-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataMixed + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -Times 1 -Exactly + Should -Invoke Uninstall-ScoopPackage -Times 2 -Exactly + } + } + + Context "When using DryRun parameter" { + It "Should pass WhatIf to both bucket and package uninstall functions" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $true } + Mock Uninstall-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataMixed -DryRun + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -ParameterFilter { $WhatIf -eq $true } -Times 1 -Exactly + Should -Invoke Uninstall-ScoopPackage -ParameterFilter { $WhatIf -eq $true } -Times 2 -Exactly + } + + It "Should return true when using DryRun with only buckets" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $true } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithBuckets -DryRun + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -ParameterFilter { $WhatIf -eq $true } -Times 2 -Exactly + } + } + + Context "When complex object formats are used" { + It "Should handle package objects with all properties" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopPackage { return $true } + + $complexData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @() + packages = @( + [PSCustomObject]@{ name = "git"; bucket = "main" }, + [PSCustomObject]@{ name = "nodejs"; version = "18.17.0"; bucket = "main"; global = $false }, + [PSCustomObject]@{ name = "python"; bucket = "main"; global = $true } + ) + } + } + } + } + + $result = Invoke-ScoopComponentUninstall -YamlData $complexData + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopPackage -ParameterFilter { $PackageName -eq "git" } -Times 1 -Exactly + Should -Invoke Uninstall-ScoopPackage -ParameterFilter { $PackageName -eq "nodejs" } -Times 1 -Exactly + Should -Invoke Uninstall-ScoopPackage -ParameterFilter { $PackageName -eq "python" -and $Global -eq $true } -Times 1 -Exactly + } + + It "Should skip packages and buckets with missing names" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $true } + Mock Uninstall-ScoopPackage { return $true } + + $invalidData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @( + [PSCustomObject]@{ source = "https://example.com" }, # Missing name + [PSCustomObject]@{ name = "extras"; source = "https://github.com/ScoopInstaller/Extras.git" }, + [PSCustomObject]@{ name = ""; source = "https://example2.com" } # Empty name + ) + packages = @( + [PSCustomObject]@{ bucket = "main" }, # Missing name + [PSCustomObject]@{ name = "git"; bucket = "main" }, + [PSCustomObject]@{ name = $null; bucket = "main" } # Null name + ) + } + } + } + } + + $result = Invoke-ScoopComponentUninstall -YamlData $invalidData + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -Times 1 -Exactly # Only the valid bucket + Should -Invoke Uninstall-ScoopPackage -Times 1 -Exactly # Only the valid package + } + + It "Should handle buckets without source property (line 132 coverage)" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $true } + + $bucketsWithoutSource = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{ + scoop = [PSCustomObject]@{ + buckets = @( + [PSCustomObject]@{ name = "main" }, # No source property + [PSCustomObject]@{ name = "extras"; source = "" } # Empty source + ) + packages = @() + } + } + } + } + + $result = Invoke-ScoopComponentUninstall -YamlData $bucketsWithoutSource + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -Times 2 -Exactly + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -eq "- Removing Scoop bucket: main" -and $ForegroundColor -eq "Gray" + } -Times 1 -Exactly + } + } + + Context "When Write-ScoopCache throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { throw "Cache write failed" } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Error writing Scoop cache*" -and $Verbosity -eq "Error" + } -Times 1 -Exactly + } + } + + Context "When Test-ScoopInstalled throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { throw "Scoop test failed" } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Scoop is not installed*" -and $Verbosity -eq "Error" + } -Times 1 -Exactly + } + } + + Context "When uninstall operations return false" { + It "Should display [FAILED] when bucket uninstall returns false (line 144 coverage)" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopBucket { return $false } # Return false instead of throwing + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithBuckets + + $result | Should -Be $true # Function still continues and returns true + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" + } -Times 2 -Exactly # Should be called for both failed buckets + } + + It "Should display [FAILED] when package uninstall returns false (line 201 coverage)" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopPackage { return $false } # Return false instead of throwing + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true # Function still continues and returns true + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" + } -Times 3 -Exactly # Should be called for all 3 failed packages + } + + It "Should display [FAILED] when package uninstall sets LASTEXITCODE to non-zero" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + Mock Uninstall-ScoopPackage { + $global:LASTEXITCODE = 1 # Set non-zero exit code + return $true + } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true # Function still continues and returns true + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" + } -Times 3 -Exactly # Should be called for all 3 packages due to LASTEXITCODE + } + } + + Context "When uninstall operations throw exceptions" { + It "Should continue processing remaining components when bucket uninstall fails" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + $script:callCount = 0 + Mock Uninstall-ScoopBucket { + $script:callCount++ + if ($script:callCount -eq 1) { throw "First bucket failed" } + return $true + } + Mock Uninstall-ScoopPackage { return $true } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataMixed + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopBucket -Times 1 -Exactly + Should -Invoke Uninstall-ScoopPackage -Times 2 -Exactly # Packages should still be processed + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to uninstall Scoop bucket*" -and $Verbosity -eq "Error" + } -Times 1 -Exactly + } + + It "Should continue processing remaining packages when package uninstall fails" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + $script:callCount = 0 + Mock Uninstall-ScoopPackage { + $script:callCount++ + if ($script:callCount -eq 1) { throw "First package failed" } + return $true + } + + $result = Invoke-ScoopComponentUninstall -YamlData $script:mockYamlDataWithPackages + + $result | Should -Be $true + Should -Invoke Uninstall-ScoopPackage -Times 3 -Exactly + # Verify Write-StatusMessage was called with error verbosity + Should -Invoke Write-StatusMessage -Times 2 -Exactly -ParameterFilter { + $Verbosity -eq "Error" + } + } + } + + Context "When configuration structure is missing or invalid" { + It "Should handle missing scoop configuration gracefully" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + + $missingData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + dependencies = [PSCustomObject]@{} + } + } + + $result = Invoke-ScoopComponentUninstall -YamlData $missingData + + $result | Should -Be $true # Should complete successfully but do nothing + } + + It "Should handle missing dependencies section" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + + $missingData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{} + } + + $result = Invoke-ScoopComponentUninstall -YamlData $missingData + + $result | Should -Be $true # Should complete successfully but do nothing + } + + It "Should handle missing devsetup section" { + Mock Test-ScoopInstalled { return $true } + Mock Write-ScoopCache { return $true } + + $missingData = [PSCustomObject]@{} + + $result = Invoke-ScoopComponentUninstall -YamlData $missingData + + $result | Should -Be $true # Should complete successfully but do nothing + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentUninstall.ps1 b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentUninstall.ps1 new file mode 100644 index 0000000..bf38327 --- /dev/null +++ b/DevSetup/Private/Providers/Scoop/Invoke-ScoopComponentUninstall.ps1 @@ -0,0 +1,212 @@ +๏ปฟ<# +.SYNOPSIS + Uninstalls multiple Scoop components (buckets and packages) from the system based on YAML configuration. + +.DESCRIPTION + This function removes multiple Scoop components specified in a DevSetup YAML configuration. + It validates Scoop installation, parses the configuration for bucket and package definitions, + and systematically uninstalls components in the correct order (buckets first, then packages). + The function supports both simple string format and complex object format for component + specifications, handles global installations, and provides comprehensive progress reporting + during the uninstallation process. + +.PARAMETER YamlData + The parsed YAML configuration data containing Scoop component definitions. + This parameter is mandatory and must be a PSCustomObject with the structure: + devsetup.dependencies.scoop containing buckets and/or packages arrays. + +.OUTPUTS + [System.Boolean] + Returns $true if all components are successfully processed (even if some individual uninstalls fail). + Returns $false if the operation encounters critical errors, Scoop is not installed, or cannot proceed. + +.EXAMPLE + $config = Read-ConfigurationFile -Path "environment.yaml" + Invoke-ScoopComponentUninstall -YamlData $config + + Uninstalls all Scoop buckets and packages defined in the environment.yaml configuration. + +.EXAMPLE + $yamlData = @{ + devsetup = @{ + dependencies = @{ + scoop = @{ + buckets = @("extras", "versions") + packages = @("git", "nodejs", "python") + } + } + } + } + Invoke-ScoopComponentUninstall -YamlData $yamlData + + Demonstrates uninstalling components using a programmatically created configuration. + +.EXAMPLE + if (Invoke-ScoopComponentUninstall -YamlData $config) { + Write-Host "All Scoop components processed successfully" + } else { + Write-Host "Scoop component uninstallation encountered errors" + } + + Shows checking the return value to verify uninstallation completion. + +.NOTES + - Requires Scoop to be installed on the system + - Uses Test-ScoopInstalled to validate Scoop availability before proceeding + - Updates Scoop cache using Write-ScoopCache before uninstallation begins + - Processes components in specific order: buckets first, then packages + - Skips uninstallation gracefully if Scoop configuration sections are not found + - Supports two component specification formats for both buckets and packages: + * Simple string: "componentname" + * Complex object: @{ name = "componentname"; version = "1.0.0"; bucket = "extras"; global = $true } + - Bucket objects support: name and source properties + - Package objects support: name, version, bucket, and global properties + - Validates component names and skips entries with missing names + - Uses Uninstall-ScoopBucket and Uninstall-ScoopPackage for individual component removal + - Provides detailed progress reporting with component counts and property information + - Uses color-coded console output: Cyan for progress, Gray for component status, Green/Red for results + - Continues processing remaining components even if individual uninstalls fail + - Returns $true for overall success even with individual component failures + - Includes comprehensive try-catch error handling with descriptive error messages + - Displays formatted component information including version, bucket, and global flags + +.LINK + +.COMPONENT + DevSetup.Providers.Scoop + +.FUNCTIONALITY + Package Management, Batch Uninstallation, Configuration Processing, Component Management +#> + +Function Invoke-ScoopComponentUninstall { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [PSCustomObject]$YamlData, + [switch]$DryRun + ) + + try { + if(-Not (Test-ScoopInstalled)) { + Write-StatusMessage "Scoop is not installed. Cannot check for components." -Verbosity Warning + return $false + } + } catch { + Write-StatusMessage "Scoop is not installed. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + if (-not (Write-ScoopCache)) { + Write-StatusMessage "Failed to write Scoop cache file: $CacheFilePath" -Verbosity Error + return $false + } + } catch { + Write-StatusMessage "Error writing Scoop cache: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + $bucketCount = 0 + Write-StatusMessage "- Uninstalling Scoop buckets from configuration:" -ForegroundColor Cyan + $buckets = $YamlData.devsetup.dependencies.scoop.buckets + # Handle buckets first if they exist in configuration + if ($buckets.Count -gt 0) { + foreach ($bucket in $buckets) { + if (-not $bucket -or [string]::IsNullOrEmpty($bucket.name)) { + Write-StatusMessage "- Skipping bucket entry, No name specified" -Verbosity "Warning" -Indent 2 -Width 112 + continue + } + + $uninstallParams = @{ + Name = $bucket.name + WhatIf = $DryRun + } + + # Use Install-ScoopBucket function to handle bucket installation + if ($bucket.name -and $bucket.source) { + Write-StatusMessage "- Removing Scoop bucket: $($bucket.name) (source: $($bucket.source))" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewLine + } else { + Write-StatusMessage "- Removing Scoop bucket: $($bucket.name)" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewLine + } + + try { + $uninstallationStatus = Uninstall-ScoopBucket @uninstallParams + } catch { + Write-StatusMessage "Failed to uninstall Scoop bucket '$($bucket.name)': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + continue + } + + if (-not $uninstallationStatus) { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + } else { + $bucketCount++ + Write-StatusMessage "[OK]" -ForegroundColor Green + } + } + } + + Write-StatusMessage "- Scoop buckets uninstallation completed! Processed $bucketCount buckets.`n" -ForegroundColor Green + + $packageCount = 0 + Write-StatusMessage "- Uninstalling Scoop packages from configuration:" -ForegroundColor Cyan + $packages = $YamlData.devsetup.dependencies.scoop.packages + + if ($packages.Count -gt 0) { + # Install packages + foreach ($package in $packages) { + if (-not $package -or [string]::IsNullOrWhiteSpace($package.name)) { continue } + + $displayName = $package.name + $uninstallParams = @{ + PackageName = $package.name + WhatIf = $DryRun + } + + $versionDisplay = "" + if ($package.version) { + $versionDisplay = "version: $($package.version)" + } + + $bucketDisplay = "" + if ($package.bucket) { + $bucketDisplay = "bucket: '$($package.bucket)'" + } + + $globalDisplay = "" + if ($package.global -eq $true) { + $globalDisplay = "global: true" + $uninstallParams.Global = $true + } + + if($versionDisplay -or $bucketDisplay -or $globalDisplay) { + $parts = @($versionDisplay, $bucketDisplay, $globalDisplay) | Where-Object { $_ } + $displayName += " (" + ($parts -join ", ") + ")" + } + + Write-StatusMessage "- Uninstalling Scoop package: $displayName" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewLine + + try { + $result = Uninstall-ScoopPackage @uninstallParams + } catch { + Write-StatusMessage "Failed to uninstall Scoop package '$($package.name)': $_" -Verbosity "Error" + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + continue + } + + if ($LASTEXITCODE -ne 0 -or -not $result) { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + } else { + Write-StatusMessage "[OK]" -ForegroundColor Green + $packageCount++ + } + } + } + + Write-StatusMessage "- Scoop packages uninstallation completed! Processed $packageCount packages.`n" -ForegroundColor Green + + return $true +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.Tests.ps1 index bb9d66f..0aedcd5 100644 --- a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.Tests.ps1 +++ b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.Tests.ps1 @@ -1,10 +1,19 @@ BeforeAll { + # Define stub functions before dot-sourcing + Function Write-EZLog {} + . $PSScriptRoot\Uninstall-ScoopBucket.ps1 . $PSScriptRoot\Test-ScoopInstalled.ps1 . $PSScriptRoot\Find-Scoop.ps1 . $PSScriptRoot\Test-ScoopComponentInstalled.ps1 . $PSScriptRoot\Write-ScoopCache.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 + . $PSScriptRoot\..\..\Utils\Write-StatusMessage.ps1 + + # Global mocks + Mock Write-EZLog { } + Mock Write-Host { } + Mock Write-Error { } } Describe "Uninstall-ScoopBucket" { @@ -27,58 +36,153 @@ Describe "Uninstall-ScoopBucket" { } Context "When bucket is already uninstalled" { - It "Should return true and debug" { + It "Should return true and display already uninstalled message" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::Pass } + $result = Uninstall-ScoopBucket -Name "extras" + $result | Should -Be $true } } Context "When bucket uninstall command fails" { - It "Should return false and warn" { + It "Should return false when bucket removal command fails" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } Mock Write-ScoopCache { return $true } - Mock Invoke-Expression { $global:LASTEXITCODE = 1 } + Mock Invoke-Command { $global:LASTEXITCODE = 1 } + $result = Uninstall-ScoopBucket -Name "extras" + $result | Should -Be $false } } Context "When Write-ScoopCache fails after uninstall" { - It "Should return false and error" { + It "Should return false when cache update fails" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } Mock Write-ScoopCache { return $false } - Mock Invoke-Expression { $global:LASTEXITCODE = 0 } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopBucket -Name "extras" + $result | Should -Be $false } } Context "When bucket is successfully uninstalled" { - It "Should return true and debug" { + It "Should return true and display success message" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } Mock Write-ScoopCache { return $true } - Mock Invoke-Expression { $global:LASTEXITCODE = 0 } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopBucket -Name "extras" + $result | Should -Be $true } } Context "When an exception occurs during uninstall" { - It "Should return false and warn" { + It "Should return false when Test-ScoopComponentInstalled throws exception" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } - Mock Test-ScoopComponentInstalled { throw "Unexpected error" } + Mock Test-ScoopComponentInstalled { throw "Unexpected error checking bucket state" } + + $result = Uninstall-ScoopBucket -Name "extras" + + $result | Should -Be $false + } + } + + Context "When exceptions occur in various operations" { + It "Should return false when Test-ScoopInstalled throws exception" { + Mock Test-ScoopInstalled { throw "Scoop check failed" } + $result = Uninstall-ScoopBucket -Name "extras" + $result | Should -Be $false } + + It "Should return false when Find-Scoop throws exception" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { throw "Cannot find Scoop command" } + + $result = Uninstall-ScoopBucket -Name "extras" + + $result | Should -Be $false + } + + It "Should return false when Invoke-Command throws exception during uninstall" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { throw "Command execution failed" } + + $result = Uninstall-ScoopBucket -Name "extras" + + $result | Should -Be $false + } + + It "Should return false when Write-ScoopCache throws exception" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + Mock Write-ScoopCache { throw "Cache write failed" } + + $result = Uninstall-ScoopBucket -Name "extras" + + $result | Should -Be $false + } + } + + Context "When using WhatIf parameter" { + It "Should not execute bucket removal when WhatIf is specified" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Write-ScoopCache { return $true } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + + $result = Uninstall-ScoopBucket -Name "extras" -WhatIf + + $result | Should -Be $true + Should -Invoke Invoke-Command -Times 0 -Exactly + # Write-ScoopCache should not be called with WhatIf due to -WhatIf:$PSCmdlet.WhatIf + Should -Invoke Write-ScoopCache -Times 0 -Exactly + } + + It "Should return true when WhatIf is used with already uninstalled bucket" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::Pass } + + $result = Uninstall-ScoopBucket -Name "extras" -WhatIf + + $result | Should -Be $true + } + } + + Context "When using ShouldProcess functionality" { + It "Should execute normally when ShouldProcess returns true" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { return [InstalledState]::NotInstalled } + Mock Write-ScoopCache { return $true } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + + $result = Uninstall-ScoopBucket -Name "extras" + + $result | Should -Be $true + Should -Invoke Invoke-Command -Times 1 -Exactly + Should -Invoke Write-ScoopCache -Times 1 -Exactly + } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.ps1 b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.ps1 index 14c2e15..3366ba7 100644 --- a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.ps1 +++ b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopBucket.ps1 @@ -63,44 +63,76 @@ #> Function Uninstall-ScoopBucket { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [string]$Name ) - if(-Not (Test-ScoopInstalled)) { + try { + if(-Not (Test-ScoopInstalled)) { + return $false + } + } catch { + Write-StatusMessage "Scoop is not installed. $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } - $scoopCommand = Find-Scoop - if (-not $scoopCommand) { + try { + $scoopCommand = Find-Scoop + if (-not $scoopCommand) { + return $false + } + } catch { + Write-StatusMessage "Failed to find Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } try { $bucketState = Test-ScoopComponentInstalled -Bucket -Name $Name - if (-not ($bucketState.HasFlag([InstalledState]::Pass))) { - # If a source is provided, add it to the command arguments - Write-Debug "Removing Scoop bucket: $Name without source" + } catch { + Write-StatusMessage "Could not verify if Scoop bucket '$Name' is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - # Execute the command to add the bucket - Invoke-Expression "& $scoopCommand bucket rm $Name" *> $null + if (-not ($bucketState.HasFlag([InstalledState]::Pass))) { + # If a source is provided, add it to the command arguments + Write-StatusMessage "Removing Scoop bucket: $Name without source" -Verbosity Debug + + # Execute the command to add the bucket + try { + if ($PSCmdlet.ShouldProcess($Name, "Uninstall Scoop bucket")) { + Invoke-Command -ScriptBlock { & $scoopCommand bucket rm $Name } *> $null + } else { + Write-StatusMessage "Skipping uninstalling Scoop bucket '$Name' due to ShouldProcess" -Verbosity Debug + return $true + } if ($LASTEXITCODE -ne 0) { return $false } - - if (-not (Write-ScoopCache)) { + } catch { + Write-StatusMessage "Failed to uninstall Scoop bucket '$Name': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + if (-not (Write-ScoopCache -WhatIf:$PSCmdlet.WhatIf)) { return $false - } - - Write-Debug "Scoop bucket '$Name' removed successfully." - return $true - } else { - Write-Debug "Scoop bucket '$Name' is already uninstalled." - return $true - } - } catch { - return $false + } + } catch { + Write-StatusMessage "Error writing Scoop cache: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "Scoop bucket '$Name' removed successfully." + return $true + } else { + Write-StatusMessage "Scoop bucket '$Name' is already uninstalled." + return $true } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopComponents.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopComponents.Tests.ps1 deleted file mode 100644 index f5e5d05..0000000 --- a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopComponents.Tests.ps1 +++ /dev/null @@ -1,137 +0,0 @@ -BeforeAll { - . $PSScriptRoot\Uninstall-ScoopComponents.ps1 - . $PSScriptRoot\Test-ScoopInstalled.ps1 - . $PSScriptRoot\Write-ScoopCache.ps1 - . $PSScriptRoot\Uninstall-ScoopBucket.ps1 - . $PSScriptRoot\Uninstall-ScoopPackage.ps1 - . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 - Mock Write-StatusMessage { } - Mock Write-Host {} - Mock Write-Error {} -} - -Describe "Uninstall-ScoopComponents" { - - Context "When Scoop is not installed" { - It "Should return false and warn" { - Mock Test-ScoopInstalled { return $false } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ - buckets = @("extras") - packages = @("git") } } } } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When Scoop configuration is missing" { - It "Should return false and warn" { - Mock Test-ScoopInstalled { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ } } } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When Write-ScoopCache fails" { - It "Should return false and error" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $false } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ - buckets = @("extras") - packages = @("git") } } } } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } - - Context "When only buckets are present and all uninstall succeed" { - It "Should return true and process all buckets" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - Mock Uninstall-ScoopBucket { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ buckets = @("extras", "versions") } } } } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When only packages are present and all uninstall succeed" { - It "Should return true and process all packages" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - Mock Uninstall-ScoopPackage { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ packages = @("git", "nodejs") } } } } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When buckets and packages are present and some uninstalls fail" { - It "Should return true and report failures" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - $bucketCallCount = 0 - Mock Uninstall-ScoopBucket -MockWith { - $bucketCallCount++ - if ($bucketCallCount -eq 1) { return $false } else { return $true } - } - $packageCallCount = 0 - Mock Uninstall-ScoopPackage -MockWith { - $packageCallCount++ - if ($packageCallCount -eq 2) { return $false } else { return $true } - } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - scoop = @{ - buckets = @("extras", "versions") - packages = @("git", "nodejs") - } - } - } - } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When no buckets or packages are present" { - It "Should return true and skip package uninstallation" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ } } } } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When an exception occurs during package uninstall" { - It "Should catch and continue, returning true" { - Mock Test-ScoopInstalled { return $true } - Mock Write-ScoopCache { return $true } - Mock Uninstall-ScoopBucket { return $true } - Mock Uninstall-ScoopPackage { throw "Unexpected error" } - $yamlData = @{ devsetup = @{ dependencies = @{ scoop = @{ packages = @("git", "nodejs") } } } } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $true - } - } - - Context "When an exception occurs in the main try block" { - It "Should return false" { - Mock Test-ScoopInstalled { throw "Critical error" } - $yamlData = @{ - devsetup = @{ - dependencies = @{ - scoop = @{ - buckets = @("extras") - packages = @("git") - } - } - } - } - $result = Uninstall-ScoopComponents -YamlData $yamlData - $result | Should -Be $false - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopComponents.ps1 b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopComponents.ps1 deleted file mode 100644 index 4012b20..0000000 --- a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopComponents.ps1 +++ /dev/null @@ -1,225 +0,0 @@ -๏ปฟ<# -.SYNOPSIS - Uninstalls multiple Scoop components (buckets and packages) from the system based on YAML configuration. - -.DESCRIPTION - This function removes multiple Scoop components specified in a DevSetup YAML configuration. - It validates Scoop installation, parses the configuration for bucket and package definitions, - and systematically uninstalls components in the correct order (buckets first, then packages). - The function supports both simple string format and complex object format for component - specifications, handles global installations, and provides comprehensive progress reporting - during the uninstallation process. - -.PARAMETER YamlData - The parsed YAML configuration data containing Scoop component definitions. - This parameter is mandatory and must be a PSCustomObject with the structure: - devsetup.dependencies.scoop containing buckets and/or packages arrays. - -.OUTPUTS - [System.Boolean] - Returns $true if all components are successfully processed (even if some individual uninstalls fail). - Returns $false if the operation encounters critical errors, Scoop is not installed, or cannot proceed. - -.EXAMPLE - $config = Read-ConfigurationFile -Path "environment.yaml" - Uninstall-ScoopComponents -YamlData $config - - Uninstalls all Scoop buckets and packages defined in the environment.yaml configuration. - -.EXAMPLE - $yamlData = @{ - devsetup = @{ - dependencies = @{ - scoop = @{ - buckets = @("extras", "versions") - packages = @("git", "nodejs", "python") - } - } - } - } - Uninstall-ScoopComponents -YamlData $yamlData - - Demonstrates uninstalling components using a programmatically created configuration. - -.EXAMPLE - if (Uninstall-ScoopComponents -YamlData $config) { - Write-Host "All Scoop components processed successfully" - } else { - Write-Host "Scoop component uninstallation encountered errors" - } - - Shows checking the return value to verify uninstallation completion. - -.NOTES - - Requires Scoop to be installed on the system - - Uses Test-ScoopInstalled to validate Scoop availability before proceeding - - Updates Scoop cache using Write-ScoopCache before uninstallation begins - - Processes components in specific order: buckets first, then packages - - Skips uninstallation gracefully if Scoop configuration sections are not found - - Supports two component specification formats for both buckets and packages: - * Simple string: "componentname" - * Complex object: @{ name = "componentname"; version = "1.0.0"; bucket = "extras"; global = $true } - - Bucket objects support: name and source properties - - Package objects support: name, version, bucket, and global properties - - Validates component names and skips entries with missing names - - Uses Uninstall-ScoopBucket and Uninstall-ScoopPackage for individual component removal - - Provides detailed progress reporting with component counts and property information - - Uses color-coded console output: Cyan for progress, Gray for component status, Green/Red for results - - Continues processing remaining components even if individual uninstalls fail - - Returns $true for overall success even with individual component failures - - Includes comprehensive try-catch error handling with descriptive error messages - - Displays formatted component information including version, bucket, and global flags - -.LINK - -.COMPONENT - DevSetup.Providers.Scoop - -.FUNCTIONALITY - Package Management, Batch Uninstallation, Configuration Processing, Component Management -#> - -Function Uninstall-ScoopComponents { - [CmdletBinding()] - Param( - [Parameter(Mandatory=$true)] - [PSCustomObject]$YamlData - ) - - try { - if(-Not (Test-ScoopInstalled)) { - Write-StatusMessage "Scoop is not installed. Cannot check for components." -Verbosity "Warning" - return $false - } - - # Check if scoop packages exist in configuration - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.scoop) { - Write-StatusMessage "Scoop configuration not found in YAML. Skipping uninstallation." -Verbosity "Warning" - return $false - } - - if (-not (Write-ScoopCache)) { - Write-Error "Failed to write Scoop cache file: $CacheFilePath" - return $false - } - - $bucketCount = 0 - # Handle buckets first if they exist in configuration - if ($YamlData.devsetup.dependencies.scoop.buckets) { - Write-StatusMessage "- Uninstalling Scoop buckets from configuration:" -ForegroundColor Cyan - foreach ($bucket in $YamlData.devsetup.dependencies.scoop.buckets) { - if (-not $bucket) { continue } - - # Handle both string format and object format - $bucketName = if ($bucket -is [string]) { $bucket } else { $bucket.name } - $bucketSource = if ($bucket -is [hashtable] -and $bucket.source) { $bucket.source } else { $null } - - $installParams = @{ - Name = $bucketName - } - - # Use Install-ScoopBucket function to handle bucket installation - if ($bucketName -and $bucketSource) { - Write-StatusMessage "- Removing Scoop bucket: $bucketName (source: $bucketSource)" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewLine - } else { - Write-StatusMessage "- Removing Scoop bucket: $bucketName" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewLine - } - - $installationStatus = Uninstall-ScoopBucket @installParams - - if (-not $installationStatus) { - Write-StatusMessage "[FAILED]" -ForegroundColor Red - } else { - $bucketCount++ - Write-StatusMessage "[OK]" -ForegroundColor Green - } - } - } - - Write-StatusMessage "- Scoop buckets uninstallation completed! Processed $bucketCount buckets." -ForegroundColor Green - - Write-Host "" - - # Check if scoop packages exist in configuration - if (-not $YamlData.devsetup.dependencies.scoop.packages) { - Write-StatusMessage "Scoop packages not found in YAML configuration. Skipping package uninstallation." -Verbosity "Warning" - return $true - } - - $scoopPackages = $YamlData.devsetup.dependencies.scoop.packages - Write-StatusMessage "- Uninstalling Scoop packages from configuration:" -ForegroundColor Cyan - - $packageCount = 0 - - # Install packages - foreach ($package in $scoopPackages) { - if (-not $package) { continue } - - $packageCount++ - - # Normalize package to object format - if ($package -is [string]) { - $packageObj = @{ name = $package } - } else { - $packageObj = $package - } - - # Validate package name - if ([string]::IsNullOrEmpty($packageObj.name)) { - Write-StatusMessage "- Skipping package entry, No name specified" -Verbosity "Warning" -Indent 2 -Width 100 - continue - } - - # Use Install-ScoopPackage function to handle the installation - try { - $displayName = $packageObj.name - $installParams = @{ - PackageName = $packageObj.name - } - - $versionDisplay = "" - if ($packageObj.version) { - $versionDisplay = "version: $($packageObj.version)" - } - - $bucketDisplay = "" - if ($packageObj.bucket) { - $bucketDisplay = "bucket: '$($packageObj.bucket)'" - } - - $globalDisplay = "" - if ($packageObj.global -eq $true) { - $globalDisplay = "global: true" - $installParams.Global = $true - } - - if($versionDisplay -or $bucketDisplay -or $globalDisplay) { - $parts = @($versionDisplay, $bucketDisplay, $globalDisplay) | Where-Object { $_ } - $displayName += " (" + ($parts -join ", ") + ")" - } - Write-StatusMessage "- Uninstalling Scoop package: $displayName" -ForegroundColor Gray -Indent 2 -Width 100 -NoNewLine - - $result = Uninstall-ScoopPackage @installParams - - if (-not $result) { - Write-StatusMessage "[FAILED]" -ForegroundColor Red - } else { - Write-StatusMessage "[OK]" -ForegroundColor Green - } - } catch { - Write-StatusMessage "Failed to uninstall Scoop package '$($packageObj.name)': $_" -Verbosity "Error" - continue - } - } - - Write-StatusMessage "- Scoop packages uninstallation completed! Processed $packageCount packages." -ForegroundColor Green - - Write-Host "" - - return $true - } - catch { - Write-StatusMessage "Error uninstalling Scoop packages: $_" -Verbosity "Error" - return $false - } -} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.Tests.ps1 index 1c7cc21..5bf2593 100644 --- a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.Tests.ps1 +++ b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.Tests.ps1 @@ -4,16 +4,23 @@ BeforeAll { . $PSScriptRoot\Find-Scoop.ps1 . $PSScriptRoot\Test-ScoopComponentInstalled.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 - + . $PSScriptRoot\..\..\Utils\Write-StatusMessage.ps1 } Describe "Uninstall-ScoopPackage" { + BeforeEach { + # Mock Write-StatusMessage to avoid console output during tests + Mock Write-StatusMessage { } + } Context "When Scoop is not installed" { It "Should return false" { Mock Test-ScoopInstalled { return $false } $result = Uninstall-ScoopPackage -PackageName "git" $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Scoop is not installed*" -and $Verbosity -eq "Debug" + } } } @@ -23,6 +30,9 @@ Describe "Uninstall-ScoopPackage" { Mock Find-Scoop { return $null } $result = Uninstall-ScoopPackage -PackageName "git" $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to find Scoop command*" -and $Verbosity -eq "Debug" + } } } @@ -31,7 +41,7 @@ Describe "Uninstall-ScoopPackage" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { - return [InstalledState]::NotInstalled + return ([InstalledState]::NotInstalled) } $result = Uninstall-ScoopPackage -PackageName "git" $result | Should -Be $true @@ -43,7 +53,7 @@ Describe "Uninstall-ScoopPackage" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { - return [InstalledState]::Pass + return ([InstalledState]::Pass) } Mock Invoke-Command { $global:LASTEXITCODE = 0 } $result = Uninstall-ScoopPackage -PackageName "git" @@ -56,7 +66,7 @@ Describe "Uninstall-ScoopPackage" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { - return [InstalledState]::Pass + return ([InstalledState]::Pass) } Mock Invoke-Command { $global:LASTEXITCODE = 1 } $result = Uninstall-ScoopPackage -PackageName "git" @@ -69,11 +79,14 @@ Describe "Uninstall-ScoopPackage" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { - return [InstalledState]::Pass + return ([InstalledState]::Pass) } Mock Invoke-Command { throw "Unexpected error" } $result = Uninstall-ScoopPackage -PackageName "git" $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to execute uninstall command for Scoop package 'git'*" -and $Verbosity -eq "Error" + } } } @@ -82,7 +95,7 @@ Describe "Uninstall-ScoopPackage" { Mock Test-ScoopInstalled { return $true } Mock Find-Scoop { return "scoop" } Mock Test-ScoopComponentInstalled { - return [InstalledState]::Pass + return ([InstalledState]::Pass) } Mock Invoke-Command { param($ScriptBlock) @@ -94,4 +107,167 @@ Describe "Uninstall-ScoopPackage" { $result | Should -Be $true } } -} \ No newline at end of file + + Context "When Test-ScoopInstalled throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { throw "Scoop test failure" } + $result = Uninstall-ScoopPackage -PackageName "git" + $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Could not verify Scoop installation*" -and $Verbosity -eq "Error" + } -Times 1 + Should -Invoke Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Times 2 + } + } + + Context "When Find-Scoop throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { throw "Scoop not found" } + $result = Uninstall-ScoopPackage -PackageName "git" + $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Error finding Scoop command*" -and $Verbosity -eq "Error" + } -Times 1 + Should -Invoke Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Times 2 + } + } + + Context "When Test-ScoopComponentInstalled throws an exception" { + It "Should return false and log error with stack trace" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { throw "Component test failure" } + $result = Uninstall-ScoopPackage -PackageName "git" + $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Could not verify if Scoop package 'git' is installed*" -and $Verbosity -eq "Error" + } -Times 1 + Should -Invoke Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Times 2 + } + } + + Context "When using WhatIf parameter" { + It "Should return true with WhatIf for installed package" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::Pass) + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopPackage -PackageName "git" -WhatIf + $result | Should -Be $true + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Invoke Find-Scoop -Times 1 + Should -Invoke Test-ScoopComponentInstalled -Times 1 + Should -Invoke Invoke-Command -Times 0 + } + + It "Should return true with WhatIf for not installed package" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::NotInstalled) + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopPackage -PackageName "git" -WhatIf + $result | Should -Be $true + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Invoke Find-Scoop -Times 1 + Should -Invoke Test-ScoopComponentInstalled -Times 1 + Should -Invoke Invoke-Command -Times 0 + } + + It "Should handle WhatIf with Global parameter" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::Pass) + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopPackage -PackageName "git" -Global -WhatIf + $result | Should -Be $true + Should -Invoke Test-ScoopInstalled -Times 1 + Should -Invoke Find-Scoop -Times 1 + Should -Invoke Test-ScoopComponentInstalled -Times 1 + Should -Invoke Invoke-Command -Times 0 + } + } + + Context "Parameter validation and edge cases" { + It "Should call Test-ScoopComponentInstalled with correct parameters for package check" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::Pass) + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopPackage -PackageName "nodejs" + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Package -eq $true -and $Name -eq "nodejs" + } + } + + It "Should handle package names with special characters" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::Pass) + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopPackage -PackageName "package-with-dashes" + $result | Should -Be $true + Should -Invoke Test-ScoopComponentInstalled -ParameterFilter { + $Name -eq "package-with-dashes" + } + } + + It "Should log debug messages appropriately" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::NotInstalled) + } + $result = Uninstall-ScoopPackage -PackageName "git" + $result | Should -Be $true + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Package not installed, can not remove*" -and $Verbosity -eq "Debug" + } + } + + It "Should log successful uninstall messages" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::Pass) + } + Mock Invoke-Command { $global:LASTEXITCODE = 0 } + $result = Uninstall-ScoopPackage -PackageName "git" + $result | Should -Be $true + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Uninstalled Scoop package: git*" -and $Verbosity -eq "Debug" + } + } + + It "Should log failed uninstall messages" { + Mock Test-ScoopInstalled { return $true } + Mock Find-Scoop { return "scoop" } + Mock Test-ScoopComponentInstalled { + return ([InstalledState]::Pass) + } + Mock Invoke-Command { $global:LASTEXITCODE = 1 } + $result = Uninstall-ScoopPackage -PackageName "git" + $result | Should -Be $false + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Failed to uninstall Scoop package: git*" -and $Verbosity -eq "Debug" + } + } + } +} diff --git a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.ps1 b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.ps1 index b411ba6..35c43e8 100644 --- a/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.ps1 +++ b/DevSetup/Private/Providers/Scoop/Uninstall-ScoopPackage.ps1 @@ -53,50 +53,67 @@ Package Management, Package Removal #> Function Uninstall-ScoopPackage { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param( [Parameter(Mandatory=$true)] [string]$PackageName, [switch]$Global ) - if(-Not (Test-ScoopInstalled)) { - Write-Debug "Scoop is not installed. Cannot check for components." + try { + if(-Not (Test-ScoopInstalled)) { + Write-StatusMessage "Scoop is not installed. Cannot check for components." -Verbosity Debug + return $false + } + } catch { + Write-StatusMessage "Could not verify Scoop installation: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } - $scoopCommand = Find-Scoop - if (-not $scoopCommand) { - Write-Debug "Failed to find Scoop command. Cannot check for components." + try { + $scoopCommand = Find-Scoop + if (-not $scoopCommand) { + Write-StatusMessage "Failed to find Scoop command. Cannot check for components." -Verbosity Debug + return $false + } + } catch { + Write-StatusMessage "Error finding Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } - $packageState = Test-ScoopComponentInstalled -Package -Name $PackageName + try { + $packageState = Test-ScoopComponentInstalled -Package -Name $PackageName + } catch { + Write-StatusMessage "Could not verify if Scoop package '$PackageName' is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } if (-not ($packageState.HasFlag([InstalledState]::Pass))) { - Write-Debug "Package not installed, can not remove." + Write-StatusMessage "Package not installed, can not remove." -Verbosity Debug return $true } - try { - $uninstallArgs = @('uninstall', $PackageName) - if($Global) { - $uninstallArgs += '--global' - } - - $command = { - & $scoopCommand @uninstallArgs *> $null - } + $uninstallArgs = @('uninstall', $PackageName) + if($Global) { + $uninstallArgs += '--global' + } - Invoke-Command -ScriptBlock $command | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Debug "Uninstalled Scoop package: $PackageName" - return $true - } else { - Write-Debug "Failed to uninstall Scoop package: $PackageName" + if ($PSCmdlet.ShouldProcess($PackageName, "Uninstall Scoop Package")) { + try { + Invoke-Command -ScriptBlock { & $scoopCommand @uninstallArgs} *> $null + } catch { + Write-StatusMessage "Failed to execute uninstall command for Scoop package '$PackageName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } - } catch { - Write-Debug "Failed to remove Scoop Package: $PackageName" + } + if ($LASTEXITCODE -eq 0) { + Write-StatusMessage "Uninstalled Scoop package: $PackageName" -Verbosity Debug + return $true + } else { + Write-StatusMessage "Failed to uninstall Scoop package: $PackageName" -Verbosity Debug return $false } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Scoop/Write-ScoopCache.Tests.ps1 b/DevSetup/Private/Providers/Scoop/Write-ScoopCache.Tests.ps1 index 992761e..3ef240a 100644 --- a/DevSetup/Private/Providers/Scoop/Write-ScoopCache.Tests.ps1 +++ b/DevSetup/Private/Providers/Scoop/Write-ScoopCache.Tests.ps1 @@ -3,61 +3,315 @@ BeforeAll { . $PSScriptRoot\Test-ScoopInstalled.ps1 . $PSScriptRoot\Find-Scoop.ps1 . $PSScriptRoot\Get-ScoopCacheFile.ps1 + + # Mock Write-StatusMessage to avoid external dependencies + function Write-StatusMessage { + param($Message, $Verbosity = "Default") + # Mock implementation + } } Describe "Write-ScoopCache" { + BeforeEach { + # Reset all mocks and global variables before each test + Mock Write-StatusMessage { } + $global:LASTEXITCODE = 0 + } + + Context "Error Handling - Get-ScoopCacheFile fails" { + It "Should return false when Get-ScoopCacheFile throws an exception" { + # Arrange + Mock Get-ScoopCacheFile { throw "Cache path error" } + Mock Write-StatusMessage { } -Verifiable + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $false + Assert-VerifiableMock + } + } + + Context "Error Handling - Test-ScoopInstalled fails" { + It "Should return false when Test-ScoopInstalled throws an exception" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { throw "Test error" } + Mock Write-StatusMessage { } -Verifiable + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $false + Assert-VerifiableMock + } + } + Context "When Scoop is not installed" { - It "Should return false and warn" { - Mock Test-ScoopInstalled { return $false } - Mock Get-ScoopCacheFile { return "C:\fakepath\scoop.cache" } + It "Should return false when Scoop is not installed" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $false } + + # Act $result = Write-ScoopCache + + # Assert $result | Should -Be $false } } - Context "When Scoop command cannot be found" { - It "Should return false and warn" { - Mock Test-ScoopInstalled { return $true } - Mock Find-Scoop { return $null } - Mock Get-ScoopCacheFile { return "C:\fakepath\scoop.cache" } + Context "Error Handling - Find-Scoop fails" { + It "Should return false when Find-Scoop throws an exception" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { throw "Find error" } + Mock Write-StatusMessage { } -Verifiable + + # Act $result = Write-ScoopCache + + # Assert $result | Should -Be $false + Assert-VerifiableMock } } - Context "When cache file is written successfully" { - It "Should return true and debug" { - Mock Test-ScoopInstalled { return $true } - Mock Find-Scoop { return "scoop" } - Mock Get-ScoopCacheFile { return "C:\fakepath\scoop.cache" } - Mock Invoke-Expression { "exported data" } - Mock Set-Content { param($Path, $Value, $Force) return $null } + Context "When Scoop command cannot be found" { + It "Should return false when Find-Scoop returns null" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { $null } + + # Act $result = Write-ScoopCache - $result | Should -Be $true + + # Assert + $result | Should -Be $false + } + + It "Should return false when Find-Scoop returns empty string" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "" } + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $false } } - Context "When writing cache file fails" { - It "Should return false and error" { - Mock Test-ScoopInstalled { return $true } - Mock Find-Scoop { return "scoop" } - Mock Get-ScoopCacheFile { return "C:\fakepath\scoop.cache" } - Mock Invoke-Expression { "exported data" } - Mock Set-Content { throw "Failed to write file" } + Context "Export Operation Failures" { + It "Should return false when Invoke-Command throws an exception" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { throw "Command failed" } + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $false + } + + It "Should return false when scoop export exits with non-zero code" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 1 + return @("some output") + } + Mock Write-StatusMessage { } -Verifiable + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $false + Assert-VerifiableMock + } + + It "Should return false when scoop export returns no data" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $null + } + Mock Write-StatusMessage { } -Verifiable + + # Act $result = Write-ScoopCache + + # Assert $result | Should -Be $false + Assert-VerifiableMock + } + + It "Should return false when scoop export returns empty array" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @() + } + Mock Write-StatusMessage { } -Verifiable + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $false + Assert-VerifiableMock } } - Context "When scoop export throws an exception" { - It "Should return false and error" { - Mock Test-ScoopInstalled { return $true } - Mock Find-Scoop { return "scoop" } - Mock Get-ScoopCacheFile { return "C:\fakepath\scoop.cache" } - Mock Invoke-Expression { throw "export failed" } + Context "Write Operation Failures" { + It "Should return false when Set-Content throws an exception" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("package1", "package2") + } + Mock Set-Content { throw "Access denied" } + Mock Write-StatusMessage { } -Verifiable + + # Act $result = Write-ScoopCache + + # Assert $result | Should -Be $false + Assert-VerifiableMock + } + } + + Context "Successful Operations" { + It "Should return true when all operations succeed" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("package1", "package2", "package3") + } + Mock Set-Content { } + Mock Write-StatusMessage { } -Verifiable + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $true + Assert-VerifiableMock + } + + It "Should call Set-Content with correct parameters" { + # Arrange + $testPath = "$TestDrive\scoop.cache" + $testData = @("package1", "package2") + Mock Get-ScoopCacheFile { $testPath } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return $testData + } + Mock Set-Content { } -ParameterFilter { + $Path -eq $testPath -and + $Force -eq $true + } -Verifiable + Mock Write-StatusMessage { } + + # Act + $result = Write-ScoopCache + + # Assert + $result | Should -Be $true + Assert-VerifiableMock + } + } + + Context "Function Properties" { + It "Should have CmdletBinding attribute" { + $function = Get-Command Write-ScoopCache + $function.CmdletBinding | Should -Be $true + } + + It "Should support ShouldProcess" { + $function = Get-Command Write-ScoopCache + $function.Parameters.ContainsKey('WhatIf') | Should -Be $true + $function.Parameters.ContainsKey('Confirm') | Should -Be $true + } + + It "Should return boolean type" { + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $false } + + $result = Write-ScoopCache + + $result | Should -BeOfType [bool] + } + } + + Context "WhatIf and ShouldProcess functionality" { + It "Should not write to cache file when WhatIf is specified" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("package1", "package2") + } + Mock Set-Content { } + + # Act + $result = Write-ScoopCache -WhatIf + + # Assert + $result | Should -Be $true + Should -Invoke Set-Content -Times 0 -Exactly + } + + It "Should return true and log debug message when WhatIf is used" { + # Arrange + Mock Get-ScoopCacheFile { "$TestDrive\scoop.cache" } + Mock Test-ScoopInstalled { $true } + Mock Find-Scoop { "scoop" } + Mock Invoke-Command { + $global:LASTEXITCODE = 0 + return @("package1", "package2") + } + Mock Set-Content { } + + # Act + $result = Write-ScoopCache -WhatIf + + # Assert + $result | Should -Be $true + Should -Invoke Write-StatusMessage -ParameterFilter { + $Message -like "*Skipping writing Scoop cache file due to ShouldProcess*" -and $Verbosity -eq "Debug" + } -Times 1 -Exactly } } -} \ No newline at end of file +} diff --git a/DevSetup/Private/Providers/Scoop/Write-ScoopCache.ps1 b/DevSetup/Private/Providers/Scoop/Write-ScoopCache.ps1 index c1d978e..0cb13d3 100644 --- a/DevSetup/Private/Providers/Scoop/Write-ScoopCache.ps1 +++ b/DevSetup/Private/Providers/Scoop/Write-ScoopCache.ps1 @@ -57,24 +57,61 @@ #> Function Write-ScoopCache { - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess=$true)] Param() - $CacheFilePath = Get-ScoopCacheFile - if(-Not (Test-ScoopInstalled)) { + try { + $CacheFilePath = Get-ScoopCacheFile + } catch { + Write-StatusMessage "Failed to get Scoop cache file path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + if(-Not (Test-ScoopInstalled)) { + return $false + } + } catch { + Write-StatusMessage "Failed to test if Scoop is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + try { + $scoopCommand = Find-Scoop + } catch { + Write-StatusMessage "Failed to find Scoop command: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } - $scoopCommand = Find-Scoop if (-not $scoopCommand) { return $false } try { - Invoke-Expression "& $scoopCommand export" | Set-Content -Path $CacheFilePath -Force | Out-Null - Write-Debug "Scoop cache written successfully: $CacheFilePath" - return $true + $exportData = Invoke-Command -ScriptBlock { & $scoopCommand export } 2>$null 3>$null 4>$null 5>$null 6>$null + if ($LASTEXITCODE -ne 0 -or -not $exportData) { + Write-StatusMessage "Failed to export Scoop package data" -Verbosity Error + return $false + } + } catch { + return $false + } + + try { + if ($PSCmdlet.ShouldProcess($CacheFilePath, "Write")) { + Set-Content -Path $CacheFilePath -Value $exportData -Encoding UTF8 -Force + } else { + Write-StatusMessage "Skipping writing Scoop cache file due to ShouldProcess" -Verbosity Debug + return $true + } } catch { + Write-StatusMessage "Failed to write Scoop cache file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } + Write-StatusMessage "Scoop cache written successfully: $CacheFilePath" -Verbosity Debug + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.Tests.ps1 b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.Tests.ps1 new file mode 100644 index 0000000..56f9d2a --- /dev/null +++ b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.Tests.ps1 @@ -0,0 +1,151 @@ +BeforeAll { + $global:LASTEXITCODE = 0 + Function Expand-Archive { + param ( + [string]$Path, + [string]$DestinationPath, + [switch]$Force + ) + } + + Mock Expand-Archive { + switch($Path) { + (Join-Path $TestDrive "test.zip") { + # Simulate successful expansion + #Write-Output "test.zip expanded successfully" + $global:LASTEXITCODE = 0 + return + } + (Join-Path $TestDrive "bad.zip") { + #Write-Output "bad.zip encountered an error" + # Simulate failed expansion + throw "Simulated bad zip" + return + } + (Join-Path $TestDrive "testdest.zip") { + switch($DestinationPath) { + (Join-Path $TestDrive "extracted") { + #Write-Output "testdest.zip expanded successfully" + # Simulate successful extraction + $global:LASTEXITCODE = 0 + return + } + (Join-Path $TestDrive "badextract") { + #Write-Output "testdest.zip encountered an error" + # Simulate failed extraction + throw "Simulated bad destination" + return + } + default { + #Write-Output "Invalid destination: $DestinationPath" + # Simulate invalid destination + $global:LASTEXITCODE = 1 + throw "Invalid destination: $DestinationPath" + } + } + } + default { + Write-Error "File not found: $Path" + # Simulate file not found + $global:LASTEXITCODE = 1 + throw "File not found: $Path" + } + } + # Simulate successful expansion + $global:LASTEXITCODE = 1 + } + . $PSScriptRoot\Expand-DevSetupUpdateArchive.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Expand-DevSetupUpdateArchive" { + + Context "When the archive file does not exist" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + $Archive = (Join-Path $TestDrive "nonexistent.zip") + $result = Expand-DevSetupUpdateArchive -Path $Archive -DestinationPath (Join-Path $TestDrive "temp") + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Archive file not found at path: $([regex]::Escape($Archive))" -and $Verbosity -eq "Error" + } + } + } + + Context "When the archive expansion fails" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "bad.zip") } + $badArchive = (Join-Path $TestDrive "bad.zip") + $goodDestination = (Join-Path $TestDrive "extracted") + $result = Expand-DevSetupUpdateArchive -Path $badArchive -DestinationPath $goodDestination + $result | Should -Be $false + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $badArchive -and $DestinationPath -eq $goodDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($badArchive)) to $([regex]::Escape($goodDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + } + } + + Context "When the archive expansion fails with bad destination" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "testdest.zip") } + $goodArchive = (Join-Path $TestDrive "testdest.zip") + $badDestination = (Join-Path $TestDrive "badextract") + $result = Expand-DevSetupUpdateArchive -Path $goodArchive -DestinationPath $badDestination + $result | Should -Be $false + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $goodArchive -and $DestinationPath -eq $badDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($goodArchive)) to $([regex]::Escape($badDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + } + } + + Context "When the archive expansion fails with invalid destination" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "testdest.zip") } + $goodArchive = (Join-Path $TestDrive "testdest.zip") + $invalidDestination = (Join-Path $TestDrive "invalid\path") + $result = Expand-DevSetupUpdateArchive -Path $goodArchive -DestinationPath $invalidDestination + $result | Should -Be $false + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $goodArchive -and $DestinationPath -eq $invalidDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($goodArchive)) to $([regex]::Escape($invalidDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Failed to expand archive:" -and $Verbosity -eq "Error" + } -Exactly 1 + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } -Exactly 2 + } + } + + Context "When the archive expansion succeeds" { + It "Should return true and log debug messages" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "test.zip") } + $goodArchive = (Join-Path $TestDrive "test.zip") + $goodDestination = (Join-Path $TestDrive "extracted") + $result = Expand-DevSetupUpdateArchive -Path $goodArchive -DestinationPath $goodDestination + $result | Should -Be $true + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $goodArchive -and $DestinationPath -eq $goodDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($goodArchive)) to $([regex]::Escape($goodDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expansion completed successfully." -and $Verbosity -eq "Debug" + } -Exactly 1 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.ps1 b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.ps1 new file mode 100644 index 0000000..0ea90dc --- /dev/null +++ b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.ps1 @@ -0,0 +1,29 @@ +Function Expand-DevSetupUpdateArchive { + [CmdletBinding()] + [OutputType([bool])] + Param( + [Parameter(Mandatory = $true, Position=0)] + [ValidateNotNullOrEmpty()] + [string]$Path, + + [Parameter(Mandatory = $true, Position=1)] + [ValidateNotNullOrEmpty()] + [string]$DestinationPath + ) + + if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { + Write-StatusMessage "Archive file not found at path: $Path" -Verbosity Error + return $false + } + + try { + Write-StatusMessage "Expanding archive file from $Path to $DestinationPath" -Verbosity Debug + Expand-Archive -Path $Path -DestinationPath $DestinationPath -Force + Write-StatusMessage "Expansion completed successfully." -Verbosity Debug + return $true + } catch { + Write-StatusMessage "Failed to expand archive: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.Tests.ps1 b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.Tests.ps1 new file mode 100644 index 0000000..c089ffc --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.Tests.ps1 @@ -0,0 +1,62 @@ +BeforeAll { + . $PSScriptRoot\Get-DevSetupModuleInstallPath.ps1 + . $PSScriptRoot\..\Providers\Powershell\Get-PowershellModuleScopeMap.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Get-DevSetupModuleInstallPath" { + Context "When DevSetup module is installed in CurrentUser scope" { + It "Should return the correct path" { + $CurrentUserPath = (Join-Path (Join-Path (Join-Path $TestDrive "Documents" ) "PowerShell" ) "Modules") + $AllUsersPath = (Join-Path (Join-Path (Join-Path $TestDrive "ProgramFiles" ) "PowerShell" ) "Modules") + Mock Get-PowershellModuleScopeMap { + return @( + @{ Scope = "CurrentUser"; Path = $CurrentUserPath }, + @{ Scope = "AllUsers"; Path = $AllUsersPath } + ) + } + + $expectedPath = Join-Path -Path $CurrentUserPath -ChildPath "DevSetup" + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $expectedPath } + + $result = Get-DevSetupModuleInstallPath + $result | Should -Be $expectedPath + } + } + + Context "When DevSetup module is installed in AllUsers scope" { + It "Should return the correct path" { + $CurrentUserPath = (Join-Path (Join-Path (Join-Path $TestDrive "Documents" ) "PowerShell" ) "Modules") + $AllUsersPath = (Join-Path (Join-Path (Join-Path $TestDrive "ProgramFiles" ) "PowerShell" ) "Modules") + Mock Get-PowershellModuleScopeMap { + return @( + @{ Scope = "CurrentUser"; Path = $CurrentUserPath }, + @{ Scope = "AllUsers"; Path = $AllUsersPath } + ) + } + + $expectedPath = Join-Path -Path $AllUsersPath -ChildPath "DevSetup" + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $expectedPath } + + $result = Get-DevSetupModuleInstallPath + $result | Should -Be $expectedPath + } + } + + Context "When DevSetup module is not installed" { + It "Should return the first scope path if module is not found" { + $CurrentUserPath = (Join-Path (Join-Path (Join-Path $TestDrive "Documents" ) "PowerShell" ) "Modules") + $AllUsersPath = (Join-Path (Join-Path (Join-Path $TestDrive "ProgramFiles" ) "PowerShell" ) "Modules") + Mock Get-PowershellModuleScopeMap { + return @( + @{ Scope = "CurrentUser"; Path = $CurrentUserPath }, + @{ Scope = "AllUsers"; Path = $AllUsersPath } + ) + } + Mock Test-Path { return $false } + $expectedPath = Join-Path -Path $CurrentUserPath -ChildPath "DevSetup" + $result = Get-DevSetupModuleInstallPath + $result | Should -Be $expectedPath + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.ps1 b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.ps1 new file mode 100644 index 0000000..1a317ad --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.ps1 @@ -0,0 +1,16 @@ +Function Get-DevSetupModuleInstallPath { + [CmdletBinding()] + Param() + + # Get the module scope map + $ScopeMap = Get-PowershellModuleScopeMap + + foreach ($Scope in $ScopeMap) { + $PotentialPath = Join-Path -Path $Scope.Path -ChildPath "DevSetup" + if (Test-Path -Path $PotentialPath) { + return $PotentialPath + } + } + + return (Join-Path ($ScopeMap | Select-Object -First 1).Path -ChildPath "DevSetup") +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupUpdateUri.Tests.ps1 b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.Tests.ps1 new file mode 100644 index 0000000..c6f82f4 --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.Tests.ps1 @@ -0,0 +1,179 @@ +BeforeAll { + Function Invoke-WebRequest { + param ( + [string]$Uri, + [switch]$UseBasicParsing + ) + } + . $PSScriptRoot\Get-DevSetupUpdateUri.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + Mock Invoke-WebRequest { + if ($Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases/latest") { + return @{ Content = @( + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/latest.zip" + tag_name = "latest" + } + ) | ConvertTo-Json } + } elseif ($Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases") { + return @{ Content = @( + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/v1.0.4.zip" + tag_name = "v1.0.4" + } + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/v1.0.3.zip" + tag_name = "v1.0.3" + } + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/v1.0.2.zip" + tag_name = "v1.0.2" + } + ) | ConvertTo-Json } + } else { + throw "Unexpected Uri: $Uri" + } + } +} + +Describe "Get-DevSetupUpdateUri" { + + Context "When Main switch is used" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should not throw" { + { Get-DevSetupUpdateUri -Main } | Should -Not -Throw + } + + It "Should return the main branch URL" { + $result = Get-DevSetupUpdateUri -Main + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/main.zip" + $result.Version | Should -Be "main" + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Main branch selected." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + + Context "When Develop switch is used" { + BeforeEach { + Mock Write-StatusMessage { } + } + + It "Should not throw" { + { Get-DevSetupUpdateUri -Develop } | Should -Not -Throw + } + + It "Should return the develop branch URL" { + $result = Get-DevSetupUpdateUri -Develop + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/develop.zip" + $result.Version | Should -Be "develop" + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Development branch selected. This may be unstable." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + Context "When no switch is used and version is latest" { + BeforeEach { + Mock Write-StatusMessage { } + } + + It "Should not throw" { + { Get-DevSetupUpdateUri } | Should -Not -Throw + } + + It "Should return the latest release URL" { + $result = Get-DevSetupUpdateUri + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/v1.0.4.zip" + $result.Version | Should -Be "latest" + Assert-MockCalled Invoke-WebRequest -ParameterFilter { $Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases" -and $UseBasicParsing -eq $true } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: latest" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + Context "When no switch is used and a specific version is given" { + BeforeEach { + Mock Write-StatusMessage { } + } + + It "Should not throw" { + { Get-DevSetupUpdateUri -Version "1.0.3" } | Should -Not -Throw + } + + It "Should return the URI for that version if it exists" { + $result = Get-DevSetupUpdateUri -Version "1.0.3" + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/v1.0.3.zip" + $result.Version | Should -Be "1.0.3" + Assert-MockCalled Invoke-WebRequest -ParameterFilter { $Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases" -and $UseBasicParsing -eq $true } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: 1.0.3" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + It "Should call write-statusmessage and return null if version does not exist" { + $result = Get-DevSetupUpdateUri -Version "9.9.9" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "No release found matching version: 9.9.9" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: 9.9.9" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + + Context "When multiple switches are used" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should throw an error due to parameter set conflict for Main and Develop" { + { Get-DevSetupUpdateUri -Main -Develop } | Should -Throw + } + It "Should throw an error due to parameter set conflict for Main and Version" { + { Get-DevSetupUpdateUri -Main -Version "1.0.3" } | Should -Throw + } + It "Should throw an error due to parameter set conflict for Develop and Version" { + { Get-DevSetupUpdateUri -Develop -Version "1.0.3" } | Should -Throw + } + } + + Context "When no parameters are provided" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should default to latest version" { + $result = Get-DevSetupUpdateUri -Version $null + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/v1.0.4.zip" + $result.Version | Should -Be "latest" + Assert-MockCalled Invoke-WebRequest -ParameterFilter { $Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases" -and $UseBasicParsing -eq $true } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: latest" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupUpdateUri.ps1 b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.ps1 new file mode 100644 index 0000000..767a8ad --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.ps1 @@ -0,0 +1,51 @@ +Function Get-DevSetupUpdateUri { + [CmdletBinding(DefaultParameterSetName="ReleaseInstall")] + [OutputType([hashtable])] + param( + [Parameter(Mandatory=$true, ParameterSetName="MainWebInstall")] + [switch]$Main, + [Parameter(Mandatory=$true, ParameterSetName="DevelopWebInstall")] + [switch]$Develop, + [Parameter(Mandatory=$false, ParameterSetName="ReleaseInstall")] + [string]$Version = "latest" + ) + + $Uri = $null + $VersionToInstall = $null + if($PSBoundParameters.ContainsKey('Main')) { + # Install the main branch + Write-StatusMessage "Main branch selected." -Verbosity Debug + $Uri = "https://github.com/pwshdevs/devsetup/archive/main.zip" + $VersionToInstall = "main" + } elseif($PSBoundParameters.ContainsKey('Develop')) { + # Install the develop branch + Write-StatusMessage "Development branch selected. This may be unstable." -Verbosity Debug + $Uri = "https://github.com/pwshdevs/devsetup/archive/develop.zip" + $VersionToInstall = "develop" + } else { + if( [string]::IsNullOrEmpty($Version)) { + $Version = "latest" + } + Write-StatusMessage "Fetching release information from GitHub..." -Verbosity Debug + # Download the the most current release and install that + $Releases = (Invoke-WebRequest -Uri https://api.github.com/repos/pwshdevs/devsetup/releases -usebasicparsing).Content | convertfrom-json + Write-StatusMessage "Fetched $(($Releases | Measure-Object).Count) releases from GitHub." -Verbosity Debug + Write-StatusMessage "Looking for version: $Version" -Verbosity Debug + if($Version -eq "latest") { + $Uri = $Releases | Select-Object -First 1 | ForEach-Object { $_.zipball_url } + $VersionToInstall = "latest" + } else { + $Uri = $Releases | Foreach-Object { if($_.tag_name -eq "v$Version") { $_.zipball_url } } + if([string]::IsNullOrEmpty($Uri)) { + Write-StatusMessage "No release found matching version: $Version" -Verbosity Error + return $null + } + $VersionToInstall = $Version + } + } + + return @{ + Uri = $Uri + Version = $VersionToInstall + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.Tests.ps1 b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.Tests.ps1 new file mode 100644 index 0000000..2145e9f --- /dev/null +++ b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.Tests.ps1 @@ -0,0 +1,95 @@ +BeforeAll { + Function Import-PowerShellDataFile { + Param([string]$Path) + } + $global:LASTEXITCODE = 0 + . $PSScriptRoot\Get-DownloadedDevSetupManifest.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Get-DownloadedDevSetupManifest" { + Context "When Invalid flags are used" { + It "Should throw an error due to missing parameter values" { + { Get-DownloadedDevSetupManifest -ModulePath $null} | Should -Throw + } + It "Should throw an error when ModulePath is blank" { + { Get-DownloadedDevSetupManifest -ModulePath "" } | Should -Throw + } + } + + Context "When a valid ModulePath is provided" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should return null when the ModulePath does not exist" { + Mock Test-Path { $false } -ParameterFilter { $Path -eq (Join-Path $TestDrive "nonexistent") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "nonexistent") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Module path not found: $([regex]::Escape((Join-Path $TestDrive "nonexistent")))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + + } + + It "Should return null when the ModulePath is not a directory" { + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "file.txt") } + Mock Get-Item { return @{ PSIsContainer = $false } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "file.txt") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "file.txt") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Module path is not a directory: $([regex]::Escape((Join-Path $TestDrive "file.txt")))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return null when the DevSetup.psd1 file is missing" { + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "nodir") } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "nodir") } + Mock Test-Path { $false } -ParameterFilter { $Path -eq (Join-Path (Join-Path $TestDrive "nodir") "DevSetup.psd1") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "nodir") + $result | Should -Be $null + } + + It "Should return null when the DevSetup.psd1 file does not contain a Version" { + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "noversion") } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "noversion") } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path (Join-Path $TestDrive "noversion") "DevSetup.psd1") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "noversion") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to read version from module manifest at path: $([regex]::Escape((Join-Path (Join-Path $TestDrive "noversion") "DevSetup.psd1")))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return null when importing the DevSetup.psd1 file throws an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "error") } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "error") } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path (Join-Path $TestDrive "error") "DevSetup.psd1") } + Mock Import-PowerShellDataFile { throw "Simulated import error" } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "error") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Error reading module manifest at path:" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Exactly 2 -Scope It + } + + It "Should return the version string when the DevSetup.psd1 file is valid" { + Mock Write-StatusMessage { + Write-Error $Message + } + $FolderPath = Join-Path $TestDrive "valid" + $PsdPath = Join-Path $FolderPath "DevSetup.psd1" + Mock Test-Path { $true } -ParameterFilter { $Path -eq $FolderPath } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq $FolderPath } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $PsdPath } + Mock Import-PowerShellDataFile { return @{ ModuleVersion = [version]"1.2.3" } } -ParameterFilter { $Path -eq $PsdPath } + $result = Get-DownloadedDevSetupManifest -ModulePath $FolderPath + $result | Should -Not -Be $null + $result | Should -BeOfType [hashtable] + $result.ModuleVersion | Should -BeOfType [version] + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.ps1 b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.ps1 new file mode 100644 index 0000000..5d79cef --- /dev/null +++ b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.ps1 @@ -0,0 +1,37 @@ +Function Get-DownloadedDevSetupManifest { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory=$true)] + [string]$ModulePath + ) + + if(-not (Test-Path $ModulePath)) { + Write-StatusMessage "Module path not found: $ModulePath" -Verbosity Error + return $null + } + + if(-not (Get-Item $ModulePath).PSIsContainer) { + Write-StatusMessage "Module path is not a directory: $ModulePath" -Verbosity Error + return $null + } + + $ModuleManifestPath = Join-Path -Path $ModulePath -ChildPath "DevSetup.psd1" + if(-not (Test-Path $ModuleManifestPath)) { + Write-StatusMessage "Module manifest not found at path: $ModuleManifestPath" -Verbosity Error + return $null + } + + try { + $ModuleManifest = Import-PowerShellDataFile -Path $ModuleManifestPath + if(-not $ModuleManifest -or -not $ModuleManifest.ModuleVersion) { + Write-StatusMessage "Failed to read version from module manifest at path: $ModuleManifestPath" -Verbosity Error + return $null + } + return $ModuleManifest + } catch { + Write-StatusMessage "Error reading module manifest at path: $ModuleManifestPath - $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-DevSetupModule.Tests.ps1 b/DevSetup/Private/Updater/Install-DevSetupModule.Tests.ps1 new file mode 100644 index 0000000..4e04b4f --- /dev/null +++ b/DevSetup/Private/Updater/Install-DevSetupModule.Tests.ps1 @@ -0,0 +1,208 @@ +BeforeAll { + Function New-Item { + Param( + [string]$ItemType, + [string]$Path, + [switch]$Force + ) + } + + Function Test-Path { + Param( + [string]$Path + ) + } + Function Copy-Item { + Param( + [string]$Path, + [string]$Destination, + [switch]$Recurse, + [switch]$Force + ) + } + . $PSScriptRoot\Install-DevSetupModule.ps1 + . $PSScriptRoot\Get-DevSetupModuleInstallPath.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + Mock Write-StatusMessage { + #Write-Error $Message + } + $global:InstallPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "Program Files" ) "WindowsPowerShell" ) "Modules" ) "DevSetup" ) + $global:ModulePath = (Join-Path (Join-Path $TestDrive "Temp" ) "DevSetup" ) +} + +Describe "Install-DevSetupModule" { + Context "When ModulePath is invalid" { + It "Should return false and log an error" { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $ModulePath } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When installation path cannot be determined" { + It "Should return false and log an error" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Get-DevSetupModuleInstallPath { return $null } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + + Context "When installation path cannot be determined is null" { + It "Should return false and log an error" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Join-Path { return $null } + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + + Context "When installation is successful" { + It "Should return true and log success message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $InstallPath } + Mock New-Item {} + Mock Copy-Item {} + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Debug' } -Exactly 1 + } + } + + Context "When user declines installation" { + It "Should return false and log a warning message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $InstallPath } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } -WhatIf:$true -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Warning' } -Exactly 1 + } + } + + Context "When installation fails due to an exception" { + It "Should return false and log an error message" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock New-Item { throw "Simulated failure" } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 2 + } + } + Context "When installation path already exists" { + It "Should skip directory creation and proceed with copying" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } + Mock Copy-Item { return $true } + Mock New-Item { } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $true + Assert-MockCalled New-Item -Exactly 0 + Assert-MockCalled Copy-Item -Exactly 1 + } + } + Context "When Copy-Item fails due to an exception" { + It "Should return false and log an error message" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock New-Item { return $true } + Mock Copy-Item { throw "Simulated copy failure" } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 2 + } + } + Context "When Manifest does not contain ModuleVersion" { + It "Should return false and log an error message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{} + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When ModulePath is invalid" { + BeforeEach { + Mock Write-StatusMessage { + Write-Error $Message + } + } + It "Should throw an error when ModulePath is null" { + { Install-DevSetupModule -ModulePath $null -Manifest @{ ModuleVersion = "1.0.0" } } | Should -Throw + } + } + Context "When Manifest is invalid" { + BeforeEach { + Mock Write-StatusMessage { + Write-Error $Message + } + } + It "Should throw error when Manifest is null" { + { Install-DevSetupModule -ModulePath $ModulePath -Manifest $null } | Should -Throw + } + } + Context "When Get-DevSetupModuleInstallPath throws an exception" { + It "Should return false and log an error message" { + Mock Get-DevSetupModuleInstallPath { throw "Simulated path retrieval failure" } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When Manifest version is a complex object" { + It "Should handle non-string version gracefully and return false" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = @{ Major = 1; Minor = 0; Patch = 0 } } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When Manifest version is an empty string" { + It "Should return false and log an error message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When using ShouldProcess functionality" { + It "Should execute normally when ShouldProcess returns true" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock New-Item {} + Mock Copy-Item {} + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } -WhatIf:$false + $result | Should -Be $true + Should -Invoke New-Item -Times 1 -Exactly + Should -Invoke Copy-Item -Times 1 -Exactly + } + + It "Should skip execution when ShouldProcess returns false" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock New-Item { return $true} + Mock Copy-Item { return $true } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } -WhatIf:$true -Confirm:$false + $result | Should -Be $true + Should -Invoke New-Item -Times 0 -Exactly + Should -Invoke Copy-Item -Times 0 -Exactly + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-DevSetupModule.ps1 b/DevSetup/Private/Updater/Install-DevSetupModule.ps1 new file mode 100644 index 0000000..0c91210 --- /dev/null +++ b/DevSetup/Private/Updater/Install-DevSetupModule.ps1 @@ -0,0 +1,63 @@ +Function Install-DevSetupModule { + [CmdletBinding(SupportsShouldProcess=$true)] + [OutputType([bool])] + Param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [String] $ModulePath, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [PSObject] $Manifest + ) + + # Determine installation path + if(-not $Manifest.ModuleVersion -or [string]::IsNullOrEmpty($Manifest.ModuleVersion) -or -not ($Manifest.ModuleVersion -is [string])) { + Write-StatusMessage "Invalid or missing version in manifest." -Verbosity Error + return $false + } + + if(-not (Test-Path -Path $ModulePath)) { + Write-StatusMessage "Invalid ModulePath: '$ModulePath'" -Verbosity Error + return $false + } + + try { + $installPath = (Join-Path (Get-DevSetupModuleInstallPath) -ChildPath $Manifest.ModuleVersion) + if ($null -eq $installPath) { + Write-StatusMessage "Failed to determine DevSetup module installation path." -Verbosity Error + return $false + } + } catch { + Write-StatusMessage "Error determining installation path: $_" -Verbosity Error + return $false + } + + if ($PSCmdlet.ShouldProcess("DevSetup Module", "Install to '$installPath'")) { + # Create installation directory if it doesn't exist + if (-not (Test-Path -Path $installPath)) { + try { + New-Item -ItemType Directory -Path $installPath -Force | Out-Null + } catch { + Write-StatusMessage "Failed to create installation directory '$installPath': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + } + + # Copy module files to installation path + try { + Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination $installPath -Recurse -Force | Out-Null + } catch { + Write-StatusMessage "Failed to copy module files to '$installPath': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "Successfully installed DevSetup module to '$installPath'." -Verbosity Debug + return $true + } else { + Write-StatusMessage "Installation of DevSetup module to '$installPath' was skipped by user." -Verbosity Warning + return $true + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-RequiredDevSetupModules.Tests.ps1 b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.Tests.ps1 new file mode 100644 index 0000000..aa6b6a9 --- /dev/null +++ b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.Tests.ps1 @@ -0,0 +1,124 @@ +BeforeAll { + Function Get-PackageProvider { + Param( + [string]$Name, + [string]$ErrorAction = "SilentlyContinue" + ) + } + + Function Install-PackageProvider { + Param( + [string]$Name, + [switch]$Force, + [switch]$ForceBootstrap + ) + } + + Function Install-Module { + Param( + [string]$Name, + [string]$Scope, + [switch]$Force + ) + } + + Function Get-Module { + Param( + [string]$Name, + [string]$ErrorAction = "SilentlyContinue" + ) + } + + . $PSScriptRoot\Install-RequiredDevSetupModules.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + + Mock Write-StatusMessage { } +} + +Describe "Install-RequiredDevSetupModules" { + Context "When NuGet provider is not installed" { + It "Should attempt to install NuGet provider and return false if installation fails" { + Mock Get-PackageProvider -MockWith { return $null } + Mock Install-PackageProvider { throw "Installation failed" } + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $false + Assert-MockCalled -CommandName Install-PackageProvider -Times 1 + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -match "Failed to install NuGet PackageProvider:" -and + $Verbosity -eq "Error" + } + } + + It "Should attempt to install NuGet provider and required modules" { + Mock Get-PackageProvider -MockWith { return $null } + Mock Install-PackageProvider + Mock Get-Module -MockWith { return $null } + Mock Install-Module + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled -CommandName Install-PackageProvider -Times 1 + foreach ($module in $modules) { + Assert-MockCalled -CommandName Install-Module -ParameterFilter { $Name -eq $module } -Times 1 + } + } + } + + Context "When NuGet provider is already installed" { + BeforeEach { + Mock Get-PackageProvider -MockWith { return @{ Name = "NuGet" } } + Mock Install-PackageProvider + Mock Get-Module -MockWith { return $null } + Mock Install-Module + } + It "Should skip installing NuGet provider and install required modules" { + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled Install-PackageProvider -Times 0 + foreach ($module in $modules) { + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq $module } -Times 1 + } + } + } + + Context "When a required module is already installed" { + BeforeEach { + Mock Get-PackageProvider -MockWith { return @{ Name = "NuGet" } } + Mock Install-PackageProvider + Mock Get-Module -MockWith { param($Name) if ($Name -eq "ModuleA") { return @{ Name = "ModuleA" } } else { return $null } } + Mock Install-Module + } + It "Should skip installing already installed modules" { + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled Install-PackageProvider -Times 0 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleA" } -Times 0 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleB" } -Times 1 + } + } + Context "When Install-Module fails for a module" { + BeforeEach { + Mock Get-PackageProvider -MockWith { return @{ Name = "NuGet" } } + Mock Install-PackageProvider + Mock Get-Module -MockWith { return $null } + Mock Install-Module -MockWith { param($Name) if ($Name -eq "ModuleB") { throw "Installation failed" } } + Mock Write-StatusMessage { } + } + It "Should log an error and continue installing other modules" { + $modules = @("ModuleA", "ModuleB", "ModuleC") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled Install-PackageProvider -Times 0 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleA" } -Times 1 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleB" } -Times 1 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleC" } -Times 1 + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to install module 'ModuleB':" -and + $Verbosity -eq "Error" + } -Times 1 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-RequiredDevSetupModules.ps1 b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.ps1 new file mode 100644 index 0000000..c34aad8 --- /dev/null +++ b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.ps1 @@ -0,0 +1,31 @@ +Function Install-RequiredDevSetupModules { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + $Modules + ) + + # Ensure NuGet provider is available + if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { + try { + Install-PackageProvider -Name NuGet -Force -ForceBootstrap *> $null + } catch { + Write-StatusMessage "Failed to install NuGet PackageProvider: $_" -Verbosity Error + return $false + } + } + + # Install required modules + foreach ($Module in $Modules) { + if (-not (Get-Module -Name $Module -ErrorAction SilentlyContinue)) { + try { + Install-Module -Name $Module -Scope CurrentUser -Force -AllowClobber *> $null + } catch { + Write-StatusMessage "Failed to install module '$Module': $_" -Verbosity Error + } + } else { + Write-StatusMessage "Module '$Module' is already installed. Skipping." -Verbosity Debug + } + } + return $true +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.Tests.ps1 b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.Tests.ps1 new file mode 100644 index 0000000..ca4d9d9 --- /dev/null +++ b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.Tests.ps1 @@ -0,0 +1,117 @@ +BeforeAll { + $global:LASTEXITCODE = 0; + Function Invoke-WebRequest { + param ( + [string]$Uri, + [string]$OutFile + ) + if($uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.0.zip" -or $uri -eq "http://api.github.com/repos/pwshdevs/devsetup/archive/main.zip" -or + $uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.2.zip" -or $uri -eq "http://api.github.com/repos/pwshdevs/devsetup/archive/develop.zip") { + $global:LASTEXITCODE = 0 + # Simulate successful download by creating an empty file + New-Item -Path $OutFile -ItemType File -Force | Out-Null + } elseif($Uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.3.zip") { + # Simulate download but file not found after download + $global:LASTEXITCODE = 0 + } elseif($Uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/write-fail.zip") { + $global:LASTEXITCODE = 1 + throw "Unable to save file" + } else { + $global:LASTEXITCODE = 1 + } + } + $global:ArchivePath = Join-Path $TestDrive "devsetup.zip" + . $PSScriptRoot\Invoke-DevSetupDownloadUpdate.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Invoke-DevSetupDownloadUpdate" { + + Context "When Invalid flags are used" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should throw an error due to missing parameter values" { + { Invoke-DevSetupDownloadUpdate -Uri } | Should -Throw + } + It "Should throw an error when both Uri is blank" { + { Invoke-DevSetupDownloadUpdate -Uri "" } | Should -Throw + } + } + + Context "When Invalid Url is provided" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should return false and log error for invalid URL" { + $result = Invoke-DevSetupDownloadUpdate -Uri "https://invalid-url.com/file.zip" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Invalid download URL: https://invalid-url.com/file.zip" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + } + + Context "When Valid Url is provided" { + BeforeEach { + Mock Write-StatusMessage { + } + Mock Join-Path { + return $ArchivePath + } + } + AfterEach { + if (Test-Path $ArchivePath) { + Remove-Item $ArchivePath -Force + } + } + It "Should return true and log info for valid URL" { + $result = Invoke-DevSetupDownloadUpdate -Uri "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.0.zip" + $result | Should -Be $ArchivePath + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Downloading update to temporary path: $([regex]::Escape($ArchivePath))" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Starting download from http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.0.zip to $([regex]::Escape($ArchivePath))" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Download completed successfully." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + + It "Should return false and log error if invoke-webrequest throws" { + $result = Invoke-DevSetupDownloadUpdate -Uri "http://api.github.com/repos/pwshdevs/devsetup/zipball/write-fail.zip" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to download update: Unable to save file" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Exactly 2 -Scope It + } + } + + Context "When Valid Url is provided but file is missing after download" { + BeforeEach { + Mock Write-StatusMessage { + } + Mock Join-Path { + return $ArchivePath + } + Mock Test-Path { $false } -ParameterFilter { $Path -eq $ArchivePath } + } + AfterEach { + if (Test-Path $ArchivePath) { + Remove-Item $ArchivePath -Force + } + } + It "Should return false and log error if file is missing after download" { + $result = Invoke-DevSetupDownloadUpdate -Uri "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.3.zip" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Download completed but file not found at $([regex]::Escape($ArchivePath))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + Assert-MockCalled Test-Path -ParameterFilter { $Path -eq $ArchivePath } -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.ps1 b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.ps1 new file mode 100644 index 0000000..c270e4e --- /dev/null +++ b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.ps1 @@ -0,0 +1,31 @@ +Function Invoke-DevSetupDownloadUpdate { + [CmdletBinding(DefaultParameterSetName="Download")] + [OutputType([string])] + Param( + [Parameter(Mandatory = $true, ParameterSetName="Download", Position=0)] + [ValidateNotNullOrEmpty()] + [string]$Uri + ) + + if(-not ($Uri -match "api.github.com/repos/pwshdevs/devsetup/zipball") -and -not ($Uri -match "github.com/pwshdevs/devsetup/archive")) { + Write-StatusMessage "Invalid download URL: $Uri" -Verbosity Error + return $null + } + + $DestinationPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "devsetup.zip" + Write-StatusMessage "Downloading update to temporary path: $DestinationPath" -Verbosity Debug + try { + Write-StatusMessage "Starting download from $Uri to $DestinationPath" -Verbosity Debug + Invoke-WebRequest -Uri $Uri -OutFile $DestinationPath + if( -not (Test-Path $DestinationPath -ErrorAction SilentlyContinue)) { + Write-StatusMessage "Download completed but file not found at $DestinationPath" -Verbosity Error + return $null + } + Write-StatusMessage "Download completed successfully." -Verbosity Debug + return $DestinationPath + } catch { + Write-StatusMessage "Failed to download update: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.Tests.ps1 b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.Tests.ps1 new file mode 100644 index 0000000..242ef9d --- /dev/null +++ b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.Tests.ps1 @@ -0,0 +1,673 @@ +BeforeAll { + # Use a temporary drive for file operations + # Source the function under test and its direct dependencies + . $PSScriptRoot\Start-DevSetupSelfUpdate.ps1 + . $PSScriptRoot\Get-DevSetupUpdateUri.ps1 + . $PSScriptRoot\Invoke-DevSetupDownloadUpdate.ps1 + . $PSScriptRoot\Expand-DevSetupUpdateArchive.ps1 + . $PSScriptRoot\Get-DownloadedDevSetupManifest.ps1 + . $PSScriptRoot\Install-RequiredDevSetupModules.ps1 + . $PSScriptRoot\Uninstall-DevSetupModule.ps1 + . $PSScriptRoot\Install-DevSetupModule.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\..\Utils\Format-RightText.ps1 + . $PSScriptRoot\..\Utils\Format-LeftText.ps1 + . $PSScriptRoot\..\Utils\Format-CenterText.ps1 + + # Global test variables + $global:TestExtractPath = Join-Path $TestDrive "devsetup_extract" + $global:TestDownloadPath = Join-Path $TestDrive "devsetup.zip" + $global:TestModulePath = Join-Path $TestDrive "DevSetup" +} + +Describe "Start-DevSetupSelfUpdate" { + + Context "Parameter Set Validation" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should accept Main parameter" { + { Start-DevSetupSelfUpdate -Main } | Should -Not -Throw + } + + It "Should accept Develop parameter" { + { Start-DevSetupSelfUpdate -Develop } | Should -Not -Throw + } + + It "Should accept Version parameter" { + { Start-DevSetupSelfUpdate -Version "1.0.0" } | Should -Not -Throw + } + + It "Should accept default parameters (no params)" { + { Start-DevSetupSelfUpdate } | Should -Not -Throw + } + + It "Should not allow Main and Develop together" { + { Start-DevSetupSelfUpdate -Main -Develop } | Should -Throw + } + + It "Should not allow Main and Version together" { + { Start-DevSetupSelfUpdate -Main -Version "1.0.0" } | Should -Throw + } + + It "Should not allow Develop and Version together" { + { Start-DevSetupSelfUpdate -Develop -Version "1.0.0" } | Should -Throw + } + } + + Context "Update URI Validation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should call Get-DevSetupUpdateUri with correct parameters for Main" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/main.zip"; Version = "main" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Get-DevSetupUpdateUri -Exactly 1 -Scope It -ParameterFilter { $Main -eq $true } + } + + It "Should call Get-DevSetupUpdateUri with correct parameters for Develop" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/develop.zip"; Version = "develop" } } + Start-DevSetupSelfUpdate -Develop + Assert-MockCalled Get-DevSetupUpdateUri -Exactly 1 -Scope It -ParameterFilter { $Develop -eq $true } + } + + It "Should call Get-DevSetupUpdateUri with correct parameters for Version" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/v2.0.0.zip"; Version = "2.0.0" } } + Start-DevSetupSelfUpdate -Version "2.0.0" + Assert-MockCalled Get-DevSetupUpdateUri -Exactly 1 -Scope It -ParameterFilter { $Version -eq "2.0.0" } + } + + It "Should return false when Get-DevSetupUpdateUri fails" { + Mock Get-DevSetupUpdateUri { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to determine update URI." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display validation status messages" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Validating Installation Type..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Download Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should call Invoke-DevSetupDownloadUpdate with correct URI" { + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Invoke-DevSetupDownloadUpdate -Exactly 1 -Scope It -ParameterFilter { + $Uri -eq "https://test.com/test.zip" + } + } + + It "Should return false when download fails" { + Mock Invoke-DevSetupDownloadUpdate { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to download update." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display download status messages" { + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Downloading update..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Extraction Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should create temporary extraction path using cross-platform methods" { + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Expand-DevSetupUpdateArchive -Exactly 1 -Scope It + } + + It "Should return false when extraction fails" { + Mock Expand-DevSetupUpdateArchive { return $false } + Mock Test-Path { return $true } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to extract update archive." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should handle extraction exceptions gracefully" { + Mock Expand-DevSetupUpdateArchive { throw "Extraction failed" } + Mock Test-Path { return $true } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to extract update archive: Extraction failed" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when extraction path doesn't exist after extraction" { + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $false } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Extraction path not found:" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display extraction status messages" { + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Extracting update..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Module Validation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should validate downloaded module manifest" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Get-DownloadedDevSetupManifest -Exactly 1 -Scope It + } + + It "Should return false when manifest is invalid" { + Mock Get-DownloadedDevSetupManifest { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to read downloaded module manifest." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when manifest has no version" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = $null; RequiredModules = @() } } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Downloaded module manifest does not contain a valid version." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when manifest has empty version" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = ""; RequiredModules = @() } } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Downloaded module manifest does not contain a valid version." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display validation status messages" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Validating downloaded module..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Prerequisites Installation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should install required modules from manifest" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @("TestModule1", "TestModule2") } } + Mock Install-RequiredDevSetupModules { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Install-RequiredDevSetupModules -Exactly 1 -Scope It -ParameterFilter { + $Modules -contains "TestModule1" -and $Modules -contains "TestModule2" + } + } + + It "Should continue on prerequisites installation failure" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @("TestModule1") } } + Mock Install-RequiredDevSetupModules { throw "Installation failed" } + Start-DevSetupSelfUpdate -Main + # Should continue to uninstall step + Assert-MockCalled Uninstall-DevSetupModule -Exactly 1 -Scope It + } + + It "Should display prerequisites status messages" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Installing required prerequisites..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Module Uninstallation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should uninstall old DevSetup module" { + Mock Uninstall-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Uninstall-DevSetupModule -Exactly 1 -Scope It + } + + It "Should return false when uninstallation fails" { + Mock Uninstall-DevSetupModule { throw "Uninstall failed" } + $result = Start-DevSetupSelfUpdate -Main + ($result | Select-Object -Last 1) | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to uninstall old DevSetup module: Uninstall failed" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display uninstallation status messages" { + Mock Uninstall-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Uninstalling old DevSetup module..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Module Installation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should install new DevSetup module" { + Mock Install-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Install-DevSetupModule -Exactly 1 -Scope It + } + + It "Should return false when installation returns false" { + Mock Install-DevSetupModule { return $false } + $result = Start-DevSetupSelfUpdate -Main + ($result | Select-Object -Last 1) | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to install new DevSetup module." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when installation throws exception" { + Mock Install-DevSetupModule { throw "Install failed" } + $result = Start-DevSetupSelfUpdate -Main + ($result | Select-Object -Last 1) | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to install new DevSetup module: Install failed" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display installation status messages" { + Mock Install-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Installing new DevSetup module..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Installation Verification Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + } + + It "Should verify module installation using Get-Module" { + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Get-Module -Exactly 1 -Scope It -ParameterFilter { + $ListAvailable -eq $true -and $Name -eq "DevSetup" + } + } + + It "Should display verification results when module is found" { + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Verifying installation..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + + It "Should display failure when module is not found" { + Mock Get-Module { return $null } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Verifying installation..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Success Path Integration" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should complete successfully with all phases working" { + $result = Start-DevSetupSelfUpdate -Main + # Should not return false (successful completion doesn't return anything) + $result | Should -Not -Be $false + } + + It "Should display completion messages on success" { + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "`nInstallation completed successfully!" -and $ForegroundColor -eq "Green" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq " Please restart your PowerShell session to use the updated module." -and $ForegroundColor -eq "White" + } -Exactly 1 -Scope It + } + + It "Should work with Version parameter" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/v1.5.0.zip"; Version = "1.5.0" } } + $result = Start-DevSetupSelfUpdate -Version "1.5.0" + $result | Should -Not -Be $false + } + + It "Should work with default parameters" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/latest.zip"; Version = "latest" } } + $result = Start-DevSetupSelfUpdate + $result | Should -Not -Be $false + } + } + + Context "Cross-Platform Path Handling" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should use Join-Path for cross-platform compatibility" { + # Create test directory structure in TestDrive + $TestExtractDir = Join-Path $TestDrive "extracted" + New-Item -Path $TestExtractDir -ItemType Directory -Force + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractDir }) } + + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } + + It "Should handle Windows path separators" { + if ($IsWindows -or $PSVersionTable.PSVersion.Major -le 5) { + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = Join-Path $TestDrive "test-extract" }) } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } + } + + It "Should handle Unix path separators" { + if ($IsLinux -or $IsMacOS) { + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = Join-Path $TestDrive "test-extract" }) } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } else { + # On Windows/PS5.1, just verify it doesn't throw + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = Join-Path $TestDrive "test-extract" }) } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } + } + } + + Context "PowerShell 5.1 Compatibility" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Start-DevSetupSelfUpdate.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible string operations" { + # Test that string operations work in PS 5.1 + { Start-DevSetupSelfUpdate -Version "test" } | Should -Not -Throw + } + + It "Should use compatible array operations" { + # Test that array/hashtable operations work in PS 5.1 + Mock Get-DownloadedDevSetupManifest { + return @{ + ModuleVersion = "1.0.0" + RequiredModules = @("Module1", "Module2") + } + } + { Start-DevSetupSelfUpdate -Main } | Should -Not -Throw + } + + It "Should work with older .NET Framework methods" { + # Test Path operations that work in .NET Framework 4.x + { Start-DevSetupSelfUpdate -Main } | Should -Not -Throw + } + } + + Context "Error Handling and Edge Cases" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should handle null return from Get-DevSetupUpdateUri" { + Mock Get-DevSetupUpdateUri { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + } + + It "Should handle empty extraction directory" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Get-ChildItem { return @() } + try { + $result = Start-DevSetupSelfUpdate -Main + # Function should handle this gracefully + $result | Should -BeIn @($null, $false) + } catch { + # It's okay if this throws an error, as the function is handling an invalid state + $_.Exception.Message | Should -Match "Cannot bind argument to parameter" + } + } + + It "Should handle manifest reading failures" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Get-DownloadedDevSetupManifest { throw "Cannot read manifest" } + try { + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + } catch { + # It's okay if this throws, as we're testing error handling + $_.Exception.Message | Should -Match "Cannot read manifest" + } + } + + It "Should display appropriate error messages for each failure point" { + Mock Get-DevSetupUpdateUri { return $null } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -eq "Failed to determine update URI." + } -Exactly 1 -Scope It + } + } + + Context "Status Message Display" { + BeforeEach { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should show progress indicators throughout the process" { + Mock Write-StatusMessage { } + Start-DevSetupSelfUpdate -Main + + # Verify all major status messages are displayed + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Validating Installation Type..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Downloading update..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Extracting update..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Validating downloaded module..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Installing required prerequisites..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Uninstalling old DevSetup module..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Installing new DevSetup module..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Verifying installation..." } -Exactly 1 -Scope It + } + + It "Should show version information" { + Mock Write-StatusMessage { } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Installing DevSetup Version..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Checking PowerShell Version..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Checking PowerShell Edition..." } -Exactly 1 -Scope It + } + + It "Should show completion messages" { + Mock Write-StatusMessage { } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "`nInstallation completed successfully!" } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "You can now use DevSetup commands in any PowerShell session." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "`nTo get started:" } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq " Please restart your PowerShell session to use the updated module." } -Exactly 1 -Scope It + } + } +} diff --git a/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.ps1 b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.ps1 new file mode 100644 index 0000000..787ea8b --- /dev/null +++ b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.ps1 @@ -0,0 +1,140 @@ +Function Start-DevSetupSelfUpdate { + [CmdletBinding(DefaultParameterSetName="ReleaseInstall")] + param( + [Parameter(Mandatory=$true, ParameterSetName="MainWebInstall")] + [switch]$Main, + [Parameter(Mandatory=$true, ParameterSetName="DevelopWebInstall")] + [switch]$Develop, + [Parameter(Mandatory=$false, ParameterSetName="ReleaseInstall")] + [string]$Version = "latest" + ) + + $successCheck = [char]0x2714 # โœ”๏ธ + $failureCheck = [char]0x2613 # + + # ------ Validate installation type and get update URI ------ + Write-StatusMessage "- Validating Installation Type..." -Width 60 -ForegroundColor Gray -NoNewLine + $UpdateChoice = Get-DevSetupUpdateUri @PSBoundParameters + if(-not $UpdateChoice) { + Write-StatusMessage "Failed to determine update URI." -Verbosity Error + return $false + } + Write-StatusMessage (Format-RightText "[$($UpdateChoice.Version)]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # ------ Download update ------ + Write-StatusMessage "- Downloading update..." -Width 60 -ForegroundColor Gray -NoNewLine + $UpdateArchive = Invoke-DevSetupDownloadUpdate -Uri $UpdateChoice.Uri + if(-not $UpdateArchive) { + Write-StatusMessage "Failed to download update." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + + # ------ Extract update ------ + Write-StatusMessage "- Extracting update..." -Width 60 -ForegroundColor Gray -NoNewLine + $ExtractPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("devsetup_update_" + [System.Guid]::NewGuid().ToString()) + Write-StatusMessage "Extracting update archive to temporary path: $ExtractPath" -Verbosity Debug + try { + if( -not (Expand-DevSetupUpdateArchive -Path $UpdateArchive -DestinationPath $ExtractPath)) { + Write-StatusMessage "Failed to extract update archive." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + } catch { + Write-StatusMessage "Failed to extract update archive: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + + if( -not $ExtractPath -or -not (Test-Path $ExtractPath -ErrorAction SilentlyContinue)) { + Write-StatusMessage "Extraction path not found: $ExtractPath" -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # ------ Validate downloaded module ------ + Write-StatusMessage "- Validating downloaded module..." -Width 60 -ForegroundColor Gray -NoNewLine + $ExtractedModulePath = (Get-ChildItem -Path $ExtractPath | Select-Object -First 1).FullName + $DownloadedModulePath = Join-Path -Path $ExtractedModulePath -ChildPath "DevSetup" + + $DownloadedManifest = Get-DownloadedDevSetupManifest -ModulePath $DownloadedModulePath + if(-not $DownloadedManifest) { + Write-StatusMessage "Failed to read downloaded module manifest." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + if(-not $DownloadedManifest.ModuleVersion -or [string]::IsNullOrEmpty($DownloadedManifest.ModuleVersion)) { + Write-StatusMessage "Downloaded module manifest does not contain a valid version." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + Write-StatusMessage "- Installing DevSetup Version..." -Width 60 -NoNewLine -ForegroundColor Gray + Write-StatusMessage (Format-RightText "[$($DownloadedManifest.ModuleVersion)]" 20) -ForegroundColor Green + + Write-StatusMessage "- Checking PowerShell Version..." -Width 60 -NoNewLine -ForegroundColor Gray + Write-StatusMessage (Format-RightText "[$($PSVersionTable.PSVersion)]" 20) -ForegroundColor Green + Write-StatusMessage "- Checking PowerShell Edition..." -Width 60 -NoNewLine -ForegroundColor Gray + Write-StatusMessage (Format-RightText "[$($PSVersionTable.PSEdition)]" 20) -ForegroundColor Green + + # --------- Install prerequisites ------------------------- + Write-StatusMessage "- Installing required prerequisites..." -Width 60 -NoNewLine -ForegroundColor Gray + try { + Install-RequiredDevSetupModules -Modules $DownloadedManifest.RequiredModules + } catch { + Write-StatusMessage "Failed to install required modules: $_" -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # --------- Uninstall old module version ------------------------- + Write-StatusMessage "- Uninstalling old DevSetup module..." -Width 60 -NoNewLine -ForegroundColor Gray + try { + Uninstall-DevSetupModule + } catch { + Write-StatusMessage "Failed to uninstall old DevSetup module: $_" -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # --------- Install new module version ------------------------- + Write-StatusMessage "- Installing new DevSetup module..." -Width 60 -NoNewLine -ForegroundColor Gray + try { + if(-not (Install-DevSetupModule -ModulePath $DownloadedModulePath -Manifest $DownloadedManifest)) { + Write-StatusMessage "Failed to install new DevSetup module." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + } catch { + Write-StatusMessage "Failed to install new DevSetup module: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + $ModuleFound = Get-Module -ListAvailable -Name "DevSetup" -ErrorAction SilentlyContinue + Write-StatusMessage "- Verifying installation..." -Width 60 -NoNewLine -ForegroundColor Gray + if ($ModuleFound) { + Write-StatusMessage (Format-RightText "[$($ModuleFound.Version)]" 20) -ForegroundColor Green + } else { + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + } + Write-StatusMessage "`nInstallation completed successfully!" -ForegroundColor Green + Write-StatusMessage "You can now use DevSetup commands in any PowerShell session." -ForegroundColor White + Write-StatusMessage "`nTo get started:" -ForegroundColor Cyan + Write-StatusMessage " Please restart your PowerShell session to use the updated module." -ForegroundColor White +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Uninstall-DevSetupModule.Tests.ps1 b/DevSetup/Private/Updater/Uninstall-DevSetupModule.Tests.ps1 new file mode 100644 index 0000000..6b4cb0b --- /dev/null +++ b/DevSetup/Private/Updater/Uninstall-DevSetupModule.Tests.ps1 @@ -0,0 +1,72 @@ +BeforeAll { + Function Remove-Item { + Param( + [string]$Path, + [switch]$Recurse, + [switch]$Force + ) + } + + Function Test-Path { + Param( + [string]$Path + ) + } + + . $PSScriptRoot\Uninstall-DevSetupModule.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\Get-DevSetupModuleInstallPath.ps1 + Mock Write-StatusMessage { } +} + +Describe "Uninstall-DevSetupModule" { + Context "When DevSetup module is installed" { + It "Should uninstall the module and return true" { + $modulePath = Join-Path -Path $TestDrive -ChildPath "DevSetup" + + Mock Get-DevSetupModuleInstallPath { return $modulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $modulePath } + Mock Remove-Item { return $true } + + $result = Uninstall-DevSetupModule + $result | Should -Be $true + + Assert-MockCalled Remove-Item -Times 1 -ParameterFilter { $Path -eq $modulePath } + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -match "Successfully uninstalled DevSetup module from '$([regex]::Escape($modulePath))'." + } + } + + It "Should handle errors during uninstallation and return false" { + $modulePath = Join-Path -Path $TestDrive -ChildPath "DevSetup" + + Mock Get-DevSetupModuleInstallPath { return $modulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $modulePath } + Mock Remove-Item { throw "Error during removal" } + + $result = Uninstall-DevSetupModule + $result | Should -Be $false + + Assert-MockCalled -CommandName Remove-Item -Times 1 -ParameterFilter { $Path -eq $modulePath } + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -match "Failed to uninstall DevSetup module from '$([regex]::Escape($modulePath))': Error during removal" -and + $Verbosity -eq "Error" + } + } + } + + Context "When DevSetup module is not installed" { + It "Should return true and indicate no action taken" { + $modulePath = Join-Path -Path $TestDrive -ChildPath "DevSetup" + Mock Get-DevSetupModuleInstallPath { return $modulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $modulePath } + + $result = Uninstall-DevSetupModule + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -eq "DevSetup module is not installed. No action taken." -and + $Verbosity -eq "Warning" + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Uninstall-DevSetupModule.ps1 b/DevSetup/Private/Updater/Uninstall-DevSetupModule.ps1 new file mode 100644 index 0000000..3965e83 --- /dev/null +++ b/DevSetup/Private/Updater/Uninstall-DevSetupModule.ps1 @@ -0,0 +1,20 @@ +Function Uninstall-DevSetupModule { + [CmdletBinding()] + [OutputType([bool])] + Param() + + $modulePath = Get-DevSetupModuleInstallPath + if ($null -ne $modulePath -and (Test-Path -Path $modulePath)) { + try { + Remove-Item -Recurse -Force -Path $modulePath | Out-Null + Write-StatusMessage "Successfully uninstalled DevSetup module from '$modulePath'." -Verbosity Debug + return $true + } catch { + Write-StatusMessage "Failed to uninstall DevSetup module from '$modulePath': $_" -Verbosity Error + return $false + } + } else { + Write-StatusMessage "DevSetup module is not installed. No action taken." -Verbosity Warning + return $true + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Assert-DevSetupEnvValid.Tests.ps1 b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.Tests.ps1 new file mode 100644 index 0000000..9130201 --- /dev/null +++ b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.Tests.ps1 @@ -0,0 +1,2081 @@ +BeforeAll { + . $PSScriptRoot\Assert-DevSetupEnvValid.ps1 + + # Helper function to create valid base configuration + function Get-ValidBaseConfig { + return @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10.0' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + chocolatey = @{ + packages = @( + @{ + name = 'git' + version = '2.0.0' + } + ) + } + } + commands = @() + } + } + } +} + +Describe "Assert-DevSetupEnvValid" { + + Context "Input validation - data types" { + It "Should accept valid hashtable input" { + $validData = Get-ValidBaseConfig + + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + $result = Assert-DevSetupEnvValid $validData + $result | Should -Be $true + } + + It "Should accept valid PSCustomObject input" { + $validData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + configuration = [PSCustomObject]@{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = [PSCustomObject]@{ + architecture = 'x64' + name = 'Windows' + version = '10.0' + } + powershell = [PSCustomObject]@{ + version = '7.0' + edition = 'Core' + } + } + dependencies = [PSCustomObject]@{ + chocolatey = [PSCustomObject]@{ + packages = @( + [PSCustomObject]@{ + name = 'git' + version = '2.0.0' + } + ) + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + $result = Assert-DevSetupEnvValid $validData + $result | Should -Be $true + } + + It "Should reject non-dictionary input types" { + { Assert-DevSetupEnvValid "invalid" } | Should -Throw "Environment data must be a hashtable or PSCustomObject." + { Assert-DevSetupEnvValid 123 } | Should -Throw "Environment data must be a hashtable or PSCustomObject." + { Assert-DevSetupEnvValid @() } | Should -Throw "Environment data must be a hashtable or PSCustomObject." + } + } + + Context "Required structure validation" { + It "Should reject data without devsetup key" { + $invalidData = @{ invalid = "data" } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Environment data must contain 'devsetup' key." + } + + It "Should reject devsetup that is not dictionary-like" { + $invalidData = @{ + devsetup = "invalid" + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "'devsetup' must be a hashtable or PSCustomObject." + } + + It "Should reject devsetup without configuration" { + $invalidData = @{ + devsetup = @{ + dependencies = @{ + chocolatey = @{ + packages = @() + } + } + commands = @() + } + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Environment data 'devsetup' section must contain 'configuration' key." + } + + It "Should reject devsetup without dependencies" { + $invalidData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10.0' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + commands = @() + } + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Environment data 'devsetup' section must contain 'dependencies' key." + } + + It "Should reject devsetup without commands" { + $invalidData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10.0' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + chocolatey = @{ + packages = @() + } + } + } + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Environment data 'devsetup' section must contain 'commands' key." + } + } + + Context "Configuration section validation" { + It "Should reject configuration that is not dictionary-like" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration = "invalid" + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "'configuration' must be a hashtable or PSCustomObject." + } + + It "Should reject configuration missing 'createdBy' field" { + $invalidData = Get-ValidBaseConfig + $configWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.Keys) { + if ($key -ne 'createdBy') { + $configWithoutField[$key] = $invalidData.devsetup.configuration[$key] + } + } + $invalidData.devsetup.configuration = $configWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration must contain 'createdBy' key.*" + } + + It "Should reject configuration missing 'description' field" { + $invalidData = Get-ValidBaseConfig + $configWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.Keys) { + if ($key -ne 'description') { + $configWithoutField[$key] = $invalidData.devsetup.configuration[$key] + } + } + $invalidData.devsetup.configuration = $configWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration must contain 'description' key.*" + } + + It "Should reject configuration missing 'lastModified' field" { + $invalidData = Get-ValidBaseConfig + $configWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.Keys) { + if ($key -ne 'lastModified') { + $configWithoutField[$key] = $invalidData.devsetup.configuration[$key] + } + } + $invalidData.devsetup.configuration = $configWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration must contain 'lastModified' key.*" + } + + It "Should reject configuration missing 'createdDate' field" { + $invalidData = Get-ValidBaseConfig + $configWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.Keys) { + if ($key -ne 'createdDate') { + $configWithoutField[$key] = $invalidData.devsetup.configuration[$key] + } + } + $invalidData.devsetup.configuration = $configWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration must contain 'createdDate' key.*" + } + + It "Should reject configuration missing 'version' field" { + $invalidData = Get-ValidBaseConfig + $configWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.Keys) { + if ($key -ne 'version') { + $configWithoutField[$key] = $invalidData.devsetup.configuration[$key] + } + } + $invalidData.devsetup.configuration = $configWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration must contain 'version' key.*" + } + + It "Should reject configuration where 'createdBy' is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration['createdBy'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'createdBy' must be a string or null.*" + } + + It "Should reject configuration where 'description' is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration['description'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'description' must be a string or null.*" + } + + It "Should reject configuration where 'lastModified' is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration['lastModified'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'lastModified' must be a string or null.*" + } + + It "Should reject configuration where 'createdDate' is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration['createdDate'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'createdDate' must be a string or null.*" + } + + It "Should reject configuration where 'version' is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration['version'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'version' must be a string or null.*" + } + + It "Should reject configuration without os section" { + $invalidData = Get-ValidBaseConfig + # Create new hashtable without the os field + $configWithoutOs = @{} + foreach ($key in $invalidData.devsetup.configuration.Keys) { + if ($key -ne 'os') { + $configWithoutOs[$key] = $invalidData.devsetup.configuration[$key] + } + } + $invalidData.devsetup.configuration = $configWithoutOs + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Configuration must contain 'os' key." + } + + It "Should reject os section that is not dictionary-like" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration.os = "invalid" + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Configuration 'os' must be a hashtable or PSCustomObject." + } + + It "Should reject os section missing 'architecture' field" { + $invalidData = Get-ValidBaseConfig + $osWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.os.Keys) { + if ($key -ne 'architecture') { + $osWithoutField[$key] = $invalidData.devsetup.configuration.os[$key] + } + } + $invalidData.devsetup.configuration.os = $osWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'os' must contain 'architecture' key.*" + } + + It "Should reject os section missing 'name' field" { + $invalidData = Get-ValidBaseConfig + $osWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.os.Keys) { + if ($key -ne 'name') { + $osWithoutField[$key] = $invalidData.devsetup.configuration.os[$key] + } + } + $invalidData.devsetup.configuration.os = $osWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'os' must contain 'name' key.*" + } + + It "Should reject os section missing 'version' field" { + $invalidData = Get-ValidBaseConfig + $osWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.os.Keys) { + if ($key -ne 'version') { + $osWithoutField[$key] = $invalidData.devsetup.configuration.os[$key] + } + } + $invalidData.devsetup.configuration.os = $osWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'os' must contain 'version' key.*" + } + + It "Should reject os 'architecture' that is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration.os['architecture'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'os.architecture' must be a string or null.*" + } + + It "Should reject os 'name' that is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration.os['name'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'os.name' must be a string or null.*" + } + + It "Should reject os 'version' that is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration.os['version'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'os.version' must be a string or null.*" + } + + It "Should reject configuration without powershell section" { + $invalidData = Get-ValidBaseConfig + # Create new hashtable without the powershell field + $configWithoutPs = @{} + foreach ($key in $invalidData.devsetup.configuration.Keys) { + if ($key -ne 'powershell') { + $configWithoutPs[$key] = $invalidData.devsetup.configuration[$key] + } + } + $invalidData.devsetup.configuration = $configWithoutPs + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Configuration must contain 'powershell' key." + } + + It "Should reject powershell section that is not dictionary-like" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration.powershell = "invalid" + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Configuration 'powershell' must be a hashtable or PSCustomObject." + } + + It "Should reject powershell section missing 'version' field" { + $invalidData = Get-ValidBaseConfig + $psWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.powershell.Keys) { + if ($key -ne 'version') { + $psWithoutField[$key] = $invalidData.devsetup.configuration.powershell[$key] + } + } + $invalidData.devsetup.configuration.powershell = $psWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'powershell' must contain 'version' key.*" + } + + It "Should reject powershell section missing 'edition' field" { + $invalidData = Get-ValidBaseConfig + $psWithoutField = @{} + foreach ($key in $invalidData.devsetup.configuration.powershell.Keys) { + if ($key -ne 'edition') { + $psWithoutField[$key] = $invalidData.devsetup.configuration.powershell[$key] + } + } + $invalidData.devsetup.configuration.powershell = $psWithoutField + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'powershell' must contain 'edition' key.*" + } + + It "Should reject powershell 'version' that is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration.powershell['version'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'powershell.version' must be a string.*" + } + + It "Should reject powershell 'edition' that is not a string" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.configuration.powershell['edition'] = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*Configuration 'powershell.edition' must be a string.*" + } + } + + Context "Dependencies section validation" { + It "Should reject dependencies that is not dictionary-like" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies = "invalid" + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "'dependencies' must be a hashtable or PSCustomObject." + } + + It "Should reject manager data that is not dictionary-like" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.chocolatey = "invalid" + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Each package manager entry must be a hashtable or PSCustomObject." + } + + It "Should reject PowerShell manager without scope" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.powershell = @{ + modules = @( + @{ + name = 'Pester' + version = '5.0.0' + minimumVersion = '4.0.0' + scope = 'CurrentUser' + } + ) + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "PowerShell manager must contain 'scope' key." + } + + It "Should reject PowerShell manager with non-string scope" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.powershell = @{ + scope = 123 + modules = @( + @{ + name = 'Pester' + version = '5.0.0' + minimumVersion = '4.0.0' + scope = 'CurrentUser' + } + ) + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "PowerShell manager 'scope' must be a string." + } + + It "Should reject manager with no package arrays" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.chocolatey = @{} + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Manager 'chocolatey' must contain at least one of: 'packages', 'modules', or 'buckets'." + } + } + + Context "Package validation" { + It "Should reject package without name" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.chocolatey.packages = @( + @{ + version = '2.0.0' + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Each packages entry for manager 'chocolatey' must contain 'name' key." + } + + It "Should reject package with empty name" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.chocolatey.packages[0].name = '' + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "'name' for packages entry must be a non-empty string." + } + + It "Should reject package with non-string name" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.chocolatey.packages[0].name = 123 + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "'name' for packages entry must be a non-empty string." + } + + It "Should reject chocolatey package without version" { + $invalidData = Get-ValidBaseConfig + # Create package without version field + $invalidData.devsetup.dependencies.chocolatey.packages = @( + @{ + name = 'git' + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "chocolatey package must contain 'version' key." + } + + It "Should accept chocolatey package with empty version (simulating YAML null)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.chocolatey.packages[0].version = '' + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should accept package with optional minimumVersion" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.chocolatey.packages[0].minimumVersion = '1.0.0' + $validData.devsetup.dependencies.chocolatey.packages[0].version = '' # Make version empty when minimumVersion is set + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should reject package with both version and minimumVersion having values" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.chocolatey.packages[0].minimumVersion = '1.0.0' + # version already has '2.0.0' from Get-ValidBaseConfig + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Cannot specify both 'version' and 'minimumVersion' with values for packages entry. Use only one." + } + } + + Context "PowerShell module validation" { + It "Should accept valid PowerShell modules" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.powershell = @{ + scope = 'CurrentUser' + modules = @( + @{ + name = 'Pester' + version = '5.0.0' + minimumVersion = '' # Empty when version is specified + scope = 'CurrentUser' + } + ) + } + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should accept PowerShell modules with minimumVersion instead of version" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.powershell = @{ + scope = 'CurrentUser' + modules = @( + @{ + name = 'Pester' + version = '' # Empty when minimumVersion is specified + minimumVersion = '4.0.0' + scope = 'CurrentUser' + } + ) + } + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should reject PowerShell module without version" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.powershell = @{ + scope = 'CurrentUser' + modules = @( + @{ + name = 'Pester' + minimumVersion = '4.0.0' + scope = 'CurrentUser' + } + ) + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "PowerShell module must contain 'version' key." + } + + It "Should reject PowerShell module without minimumVersion" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.powershell = @{ + scope = 'CurrentUser' + modules = @( + @{ + name = 'Pester' + version = '5.0.0' + scope = 'CurrentUser' + } + ) + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "PowerShell module must contain 'minimumVersion' key." + } + + It "Should reject PowerShell module without scope" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.powershell = @{ + scope = 'CurrentUser' + modules = @( + @{ + name = 'Pester' + version = '5.0.0' + minimumVersion = '4.0.0' + } + ) + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "PowerShell module must contain 'scope' key." + } + } + + Context "Scoop validation" { + It "Should accept valid Scoop configuration" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.scoop = @{ + buckets = @( + @{ + name = 'extras' + source = 'https://github.com/ScoopInstaller/Extras' + } + ) + packages = @( + @{ + name = 'vscode' + version = '1.0.0' + bucket = 'extras' + } + ) + } + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should reject Scoop package without bucket" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.scoop = @{ + packages = @( + @{ + name = 'vscode' + version = '1.0.0' + } + ) + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Scoop package must contain 'bucket' key." + } + + It "Should reject bucket without source" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.dependencies.scoop = @{ + buckets = @( + @{ + name = 'extras' + } + ) + } + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "scoop bucket must contain 'source' key." + } + } + + Context "Commands validation" { + It "Should accept valid commands" { + $validData = Get-ValidBaseConfig + $validData.devsetup.commands = @( + @{ + command = 'npm install' + packageName = 'nodejs-setup' + params = @{ + globalFlag = '-g' + package = 'typescript' + } + } + ) + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should accept empty commands array" { + $validData = Get-ValidBaseConfig + # commands is already empty from Get-ValidBaseConfig + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should reject command without command field" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.commands = @( + @{ + packageName = 'test' + params = @{} + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Each command entry must contain 'command' key." + } + + It "Should reject command with empty command field" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.commands = @( + @{ + command = '' + packageName = 'test' + params = @{} + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "'command' must be a non-empty string." + } + + It "Should reject command without packageName field" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.commands = @( + @{ + command = 'test' + params = @{} + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Each command entry must contain 'packageName' key." + } + + It "Should reject command with empty packageName field" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.commands = @( + @{ + command = 'test' + packageName = '' + params = @{} + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "'packageName' must be a non-empty string." + } + + It "Should reject command without params field" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.commands = @( + @{ + command = 'test' + packageName = 'test' + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Each command entry must contain 'params' key." + } + + It "Should accept command with null parameters" { + $validData = Get-ValidBaseConfig + $validData.devsetup.commands = @( + @{ + command = 'test' + packageName = 'test' + params = @{ + param1 = 'value1' + param2 = $null + param3 = 'value2' + } + } + ) + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + } + + It "Should reject command with non-string parameters" { + $invalidData = Get-ValidBaseConfig + $invalidData.devsetup.commands = @( + @{ + command = 'test' + packageName = 'test' + params = @{ + param1 = 'valid-param' + param2 = 123 + param3 = 'another-valid-param' + } + } + ) + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "Each parameter value in 'params' hashtable must be a string or null." + } + } + + Context "Helper function edge cases" { + It "Should handle Test-KeyExists with unsupported object types" { + # Create an object that's neither hashtable nor PSCustomObject + $unsupportedObj = "string_object" + + # Create minimal valid environment data with this problematic object as repositories + $invalidData = @{ + repositories = $unsupportedObj + packages = @{ + chocolatey = @() + winget = @() + } + commands = @() + } + + { Assert-DevSetupEnvValid $invalidData } | Should -Throw + } + + It "Should handle Get-Value with unsupported object types" { + # Create a custom object that doesn't match expected types + $customObj = [System.Collections.ArrayList]::new() + + # Create environment data that will trigger Get-Value fallback + $invalidData = @{ + repositories = @() + packages = $customObj + commands = @() + } + + { Assert-DevSetupEnvValid $invalidData } | Should -Throw + } + } + + Context "YAML parsing edge cases" { + It "Should handle null values from YAML parsing" { + $yamlData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + configuration = [PSCustomObject]@{ + createdBy = 'test' + description = $null # Simulate YAML null + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = [PSCustomObject]@{ + architecture = 'x64' + name = 'Windows' + version = $null # Simulate YAML null + } + powershell = [PSCustomObject]@{ + version = '7.0' + edition = 'Core' + } + } + dependencies = [PSCustomObject]@{ + chocolatey = [PSCustomObject]@{ + packages = @( + [PSCustomObject]@{ + name = 'git' + version = $null # Simulate YAML null + } + ) + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle List objects from YAML parsing" { + $yamlData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + configuration = [PSCustomObject]@{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = [PSCustomObject]@{ + architecture = 'x64' + name = 'Windows' + version = '10.0' + } + powershell = [PSCustomObject]@{ + version = '7.0' + edition = 'Core' + } + } + dependencies = [PSCustomObject]@{ + chocolatey = [PSCustomObject]@{ + packages = [System.Collections.Generic.List[System.Object]]@( + [PSCustomObject]@{ + name = 'git' + version = '2.0.0' + } + ) + } + } + commands = [System.Collections.Generic.List[System.Object]]@() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + } + + Context "Commands validation edge cases" { + It "Should reject commands if not array or List (line 148)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.commands = "invalid_string_instead_of_array" + + { Assert-DevSetupEnvValid $validData } | Should -Throw "'commands' must be an array." + } + + It "Should reject command without 'command' key (line 152)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.commands = @( + @{ + packageName = 'test-package' + params = @{} + } + ) + + { Assert-DevSetupEnvValid $validData } | Should -Throw "Each command entry must contain 'command' key." + } + + It "Should reject command params if not hashtable or PSCustomObject (line 200)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.commands = @( + @{ + command = 'test-command' + packageName = 'test-package' + params = "invalid_string_instead_of_hashtable" + } + ) + + { Assert-DevSetupEnvValid $validData } | Should -Throw "'params' must be a hashtable or PSCustomObject." + } + + It "Should reject non-string parameter in params hashtable (line 208)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.commands = @( + @{ + command = 'test-command' + packageName = 'test-package' + params = @{ + validParam = 'valid-value' + invalidParam = 123 + anotherValidParam = 'another-valid-value' + } + } + ) + + { Assert-DevSetupEnvValid $validData } | Should -Throw "Each parameter value in 'params' hashtable must be a string or null." + } + } + + Context "Dependencies validation edge cases" { + It "Should reject dependencies if not dictionary-like (line 216)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies = "invalid_string_instead_of_object" + + { Assert-DevSetupEnvValid $validData } | Should -Throw "'dependencies' must be a hashtable or PSCustomObject." + } + + It "Should reject package manager entry if not dictionary-like (line 230)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.chocolatey = "invalid_string_instead_of_object" + + { Assert-DevSetupEnvValid $validData } | Should -Throw "Each package manager entry must be a hashtable or PSCustomObject." + } + + It "Should reject bucket without source key (line 356)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.scoop = @{ + buckets = @( + @{ + name = 'test-bucket' + # missing source key + } + ) + packages = @() + } + + { Assert-DevSetupEnvValid $validData } | Should -Throw "scoop bucket must contain 'source' key." + } + + It "Should reject bucket source if not string (line 361)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.scoop = @{ + buckets = @( + @{ + name = 'test-bucket' + source = 123 # not a string + } + ) + packages = @() + } + + { Assert-DevSetupEnvValid $validData } | Should -Throw "scoop bucket 'source' must be a string." + } + + It "Should reject both version and minimumVersion with values (line 431)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.chocolatey = @{ + packages = @( + @{ + name = 'test-package' + version = '1.0.0' + minimumVersion = '0.9.0' # Both specified - should fail + } + ) + } + + { Assert-DevSetupEnvValid $validData } | Should -Throw "Cannot specify both 'version' and 'minimumVersion' with values for packages entry. Use only one." + } + + It "Should reject package manager with no arrays (line 437)" { + $validData = Get-ValidBaseConfig + $validData.devsetup.dependencies.emptymanager = @{ + # No packages, modules, or buckets arrays + } + + { Assert-DevSetupEnvValid $validData } | Should -Throw "Manager 'emptymanager' must contain at least one of: 'packages', 'modules', or 'buckets'." + } + } + + Context "Complex valid scenarios" { + It "Should accept comprehensive valid configuration" { + $validData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10.0' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + powershell = @{ + scope = 'CurrentUser' + modules = @( + @{ + name = 'Pester' + version = '5.0.0' + minimumVersion = '' # Empty when version is specified + scope = 'CurrentUser' + } + ) + } + chocolatey = @{ + packages = @( + @{ + name = 'git' + version = '2.0.0' + }, + @{ + name = 'nodejs' + minimumVersion = '14.0.0' + version = '' # Empty when using minimumVersion + } + ) + } + scoop = @{ + buckets = @( + @{ + name = 'extras' + source = 'https://github.com/ScoopInstaller/Extras' + } + ) + packages = @( + @{ + name = 'vscode' + version = '1.0.0' + bucket = 'extras' + } + ) + } + } + commands = @( + @{ + command = 'npm install' + packageName = 'nodejs-setup' + params = @{ + globalFlag = '-g' + package = 'typescript' + } + } + ) + } + } + + { Assert-DevSetupEnvValid $validData } | Should -Not -Throw + $result = Assert-DevSetupEnvValid $validData + $result | Should -Be $true + } + } + + Context "Edge cases for 100% coverage" { + It "Should handle invalid object types in helper functions" { + # Test the fallback paths in helper functions that return $false and $null + $invalidData = [PSCustomObject]@{ + devsetup = "not a valid object type" # string instead of object + } + + { Assert-DevSetupEnvValid $invalidData } | Should -Throw "*'devsetup' must be a hashtable or PSCustomObject.*" + } + + It "Should handle commands as PSCustomObject with numeric properties" { + $yamlData = [PSCustomObject]@{ + devsetup = [PSCustomObject]@{ + configuration = [PSCustomObject]@{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = [PSCustomObject]@{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = [PSCustomObject]@{ + version = '7.0' + edition = 'Core' + } + } + dependencies = [PSCustomObject]@{ + chocolatey = [PSCustomObject]@{ + packages = @() + } + } + commands = [PSCustomObject]@{ + '0' = [PSCustomObject]@{ + command = 'echo test' + packageName = 'test' + params = @{ + param1 = 'value1' + } + } + } + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle params as PSCustomObject with numeric properties" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + chocolatey = @{ + packages = @() + } + } + commands = @( + @{ + command = 'echo test' + packageName = 'test' + params = [PSCustomObject]@{ + '0' = 'param1' + '1' = 'param2' + } + } + ) + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle params as hashtable with numeric keys" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + chocolatey = @{ + packages = @() + } + } + commands = @( + @{ + command = 'echo test' + packageName = 'test' + params = @{ + '0' = 'param1' + '1' = 'param2' + } + } + ) + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should accept params as hashtable with non-numeric keys (single item)" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + chocolatey = @{ + packages = @() + } + } + commands = @( + @{ + command = 'echo test' + packageName = 'test' + params = @{ + param = 'value' + } + } + ) + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle PowerShell modules as PSCustomObject with numeric properties" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + powershell = @{ + scope = 'CurrentUser' + modules = [PSCustomObject]@{ + '0' = @{ + name = 'Pester' + version = '5.7.1' + minimumVersion = '' + scope = 'CurrentUser' + } + } + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle PowerShell modules as hashtable with numeric keys" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + powershell = @{ + scope = 'CurrentUser' + modules = @{ + '0' = @{ + name = 'Pester' + version = '5.7.1' + minimumVersion = '' + scope = 'CurrentUser' + } + } + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle PowerShell modules as single hashtable (non-numeric keys)" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + powershell = @{ + scope = 'CurrentUser' + modules = @{ + name = 'Pester' + version = '5.7.1' + minimumVersion = '' + scope = 'CurrentUser' + } + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle package arrays as PSCustomObject with numeric properties" { + $managers = @('chocolatey', 'scoop', 'winget') + foreach ($manager in $managers) { + $dependencies = @{} + $dependencies[$manager] = @{ + packages = [PSCustomObject]@{ + '0' = @{ + name = 'git' + version = '2.40.0' + } + } + } + + if ($manager -eq 'scoop') { + $dependencies[$manager].packages.'0'.bucket = 'main' + } + + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = $dependencies + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + } + + It "Should handle package arrays as hashtable with numeric keys" { + $managers = @('chocolatey', 'scoop', 'winget') + foreach ($manager in $managers) { + $dependencies = @{} + $dependencies[$manager] = @{ + packages = @{ + '0' = @{ + name = 'git' + version = '2.40.0' + } + } + } + + if ($manager -eq 'scoop') { + $dependencies[$manager].packages.'0'.bucket = 'main' + } + + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = $dependencies + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + } + + It "Should handle package arrays as single hashtable (non-numeric keys)" { + $managers = @('chocolatey', 'scoop', 'winget') + foreach ($manager in $managers) { + $dependencies = @{} + $dependencies[$manager] = @{ + packages = @{ + name = 'git' + version = '2.40.0' + } + } + + if ($manager -eq 'scoop') { + $dependencies[$manager].packages.bucket = 'main' + } + + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = $dependencies + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + } + + It "Should handle Scoop buckets as PSCustomObject with numeric properties" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + scoop = @{ + buckets = [PSCustomObject]@{ + '0' = @{ + name = 'extras' + source = 'https://github.com/ScoopInstaller/Extras.git' + } + } + packages = @() + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle Scoop buckets as hashtable with numeric keys" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + scoop = @{ + buckets = @{ + '0' = @{ + name = 'extras' + source = 'https://github.com/ScoopInstaller/Extras.git' + } + } + packages = @() + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should handle Scoop buckets as single hashtable (non-numeric keys)" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + scoop = @{ + buckets = @{ + name = 'extras' + source = 'https://github.com/ScoopInstaller/Extras.git' + } + packages = @() + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Not -Throw + } + + It "Should reject Scoop packages with invalid bucket field types" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + scoop = @{ + packages = @( + @{ + name = 'git' + bucket = 123 # Invalid - should be string + version = '2.0.0' + } + ) + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Throw "*Scoop package 'bucket' must be a string*" + } + + It "Should reject invalid field types in generic package arrays" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + winget = @{ + packages = @( + @{ + name = 123 # Invalid - should be string + version = '1.0.0' + } + ) + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Throw "*'name' for packages entry must be a non-empty string*" + } + + It "Should reject invalid version field types in generic package arrays" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + winget = @{ + packages = @( + @{ + name = 'git' + version = 123 # Invalid - should be string + } + ) + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Throw "*package 'version' must be a string*" + } + + It "Should reject invalid minimumVersion field types in generic package arrays" { + $yamlData = @{ + devsetup = @{ + configuration = @{ + createdBy = 'test' + description = 'test' + lastModified = '2023-01-01' + createdDate = '2023-01-01' + version = '1.0' + os = @{ + architecture = 'x64' + name = 'Windows' + version = '10' + } + powershell = @{ + version = '7.0' + edition = 'Core' + } + } + dependencies = @{ + winget = @{ + packages = @( + @{ + name = 'git' + minimumVersion = 123 # Invalid - should be string + version = '' + } + ) + } + } + commands = @() + } + } + + { Assert-DevSetupEnvValid $yamlData } | Should -Throw "*package 'minimumVersion' must be a string*" + } + } + + Context "Test-KeyExists Coverage Gaps" { + It "should handle unsupported object types" { + # Line 14 - return false for unsupported object types + $unsupportedObject = @(1, 2, 3) # array is not hashtable or PSCustomObject + $result = Test-KeyExists $unsupportedObject 'somekey' + $result | Should -Be $false + } + } + + Context "Get-Value Coverage Gaps" { + It "should return null for unsupported object types" { + # Line 34 - return null for unsupported object types + $unsupportedObject = @(1, 2, 3) # array is not hashtable or PSCustomObject + $result = Get-Value $unsupportedObject 'somekey' + $result | Should -BeNullOrEmpty + } + } + + Context "ConvertTo-NormalizedArray Coverage Gaps" { + It "should handle null input properly" { + # Line 53 - handle null input case + $result = ConvertTo-NormalizedArray $null + # The function returns ,@() which is an empty array + # PowerShell may unwrap it, so we check the behavior not the type + $resultArray = @($result) # Force into array context + $resultArray.Count | Should -Be 0 + # The function should not return null - it should return an empty array + # This tests that line 53: return ,@() is executed + } + + It "should handle PSCustomObject with mixed properties" { + # Line 73 - PSCustomObject with non-numeric properties + $mixedObject = [PSCustomObject]@{ + '0' = 'first' + 'name' = 'test' + '1' = 'second' + } + $result = ConvertTo-NormalizedArray $mixedObject + # Should wrap single object in array since not all properties are numeric + # The function returns ,@($InputObject) for mixed properties + @($result).Count | Should -Be 1 + $result[0] | Should -Be $mixedObject + } + } + + Context "Assert-CommandsValid Coverage Gaps" { + It "should throw error for empty command string" { + # Line 191 - validate non-empty command strings + $invalidCommands = @( + [PSCustomObject]@{ + command = "" # empty string should fail + packageName = "test-package" + params = @() + } + ) + + { Assert-CommandsValid $invalidCommands } | Should -Throw "*must be a non-empty string*" + } + } + + Context "Assert-PackageArrayValid Coverage Gaps" { + It "should validate that ConvertTo-NormalizedArray produces arrays for package validation" { + # Line 295 - test that normalized items are arrays + # The key is that ConvertTo-NormalizedArray should produce an array + # Create a test that exercises the normalization path + $singlePackage = [PSCustomObject]@{ + name = "git" + version = "2.40.0" + } + + # This should work because ConvertTo-NormalizedArray will wrap it in an array + { Assert-PackageArrayValid -ManagerName "chocolatey" -ArrayType "packages" -Items $singlePackage } | Should -Not -Throw + + # This tests the actual error condition - when normalization fails to produce an array + # Use a string that won't normalize to an array to trigger line 295 + Mock ConvertTo-NormalizedArray { return "not-an-array" } + { Assert-PackageArrayValid -ManagerName "chocolatey" -ArrayType "packages" -Items $singlePackage } | Should -Throw "*must be an array*" + } + } + + Context "Assert-PackageItemValid Coverage Gaps" { + It "should throw error for non-dictionary package item" { + # Line 315 - validate dictionary-like item requirement + $nonDictItem = "just-a-string" + + { Assert-PackageItemValid -ManagerName "chocolatey" -ArrayType "packages" -Item $nonDictItem } | Should -Throw "*must be a hashtable or PSCustomObject*" + } + } + + Context "Assert-PowerShellModuleValid Coverage Gaps" { + It "should throw error when required field has non-string value" { + # Line 359 - validate string type for required fields + $invalidModule = [PSCustomObject]@{ + name = "TestModule" + version = 123 # non-string version should fail + minimumVersion = "1.0" + scope = "CurrentUser" + } + + { Assert-PowerShellModuleValid $invalidModule } | Should -Throw "*must be a string*" + } + } + + Context "Assert-GenericPackageValid Coverage Gaps" { + It "should throw error when minimumVersion is not a string" { + # Line 399 - validate minimumVersion string type + $invalidPackage = [PSCustomObject]@{ + name = "TestPackage" + version = "1.0" + minimumVersion = 123 # non-string minimumVersion should fail + } + + { Assert-GenericPackageValid $invalidPackage "chocolatey" } | Should -Throw "*minimumVersion' must be a string*" + } + } + + Context "Assert-VersionFieldsValid Coverage Gaps" { + It "should throw error when version field is not a string" { + # Line 436 - validate version field string type + $itemWithInvalidVersion = [PSCustomObject]@{ + name = "TestItem" + version = 123 # non-string version + minimumVersion = "" + } + + { Assert-VersionFieldsValid $itemWithInvalidVersion "package" } | Should -Throw "*version*must be a string*" + } + + It "should throw error when minimumVersion field is not a string" { + # Line 439 - validate minimumVersion field string type + $itemWithInvalidMinVersion = [PSCustomObject]@{ + name = "TestItem" + version = "" + minimumVersion = 123 # non-string minimumVersion + } + + { Assert-VersionFieldsValid $itemWithInvalidMinVersion "package" } | Should -Throw "*minimumVersion*must be a string*" + } + } + + Context "Assert-DependenciesValid Coverage Gaps" { + It "should handle PSCustomObject dependencies properly" { + # Line 469 - PSCustomObject path in dependencies validation + $dependencies = [PSCustomObject]@{ + chocolatey = [PSCustomObject]@{ + packages = @( + [PSCustomObject]@{ + name = "git" + version = "2.40.0" + } + ) + } + } + + { Assert-DependenciesValid $dependencies } | Should -Not -Throw + } + } + + Context "Edge Cases and Integration Tests" { + It "should handle complex nested structures with various data types" { + # Test multiple coverage gaps in a single complex scenario + $complexEnv = @{ + devsetup = @{ + configuration = @{ + createdBy = "TestUser" + description = "Test environment" + lastModified = "2025-01-01" + createdDate = "2025-01-01" + version = "1.0" + os = @{ + architecture = "x64" + name = "Windows" + version = "10" + } + powershell = @{ + version = "7.3.0" + edition = "Core" + } + } + dependencies = [PSCustomObject]@{ # Mix of hashtables and PSCustomObjects + chocolatey = @{ + packages = @( + @{ + name = "git" + version = $null # null version should be handled + minimumVersion = $null # null minimumVersion should be handled + } + ) + } + powershell = [PSCustomObject]@{ + scope = "CurrentUser" + modules = @( + [PSCustomObject]@{ + name = "Pester" + version = "" + minimumVersion = "5.0" + scope = "CurrentUser" + } + ) + } + } + commands = @( + @{ + command = "git --version" + packageName = "git" + params = @{ + flag1 = $null + flag2 = "" + } # Mix of null and empty params in hashtable + } + ) + } + } + + { Assert-DevSetupEnvValid $complexEnv } | Should -Not -Throw + } + + It "should properly validate all error conditions in sequence" { + # Test that covers multiple validation paths + + # First test: invalid root structure + { Assert-DevSetupEnvValid "not-an-object" } | Should -Throw "*must be a hashtable or PSCustomObject*" + + # Second test: invalid dependencies structure + $invalidDeps = @{ + devsetup = @{ + configuration = @{ + createdBy = "Test" + description = "Test" + lastModified = "2025-01-01" + createdDate = "2025-01-01" + version = "1.0" + os = @{ architecture = "x64"; name = "Windows"; version = "10" } + powershell = @{ version = "7.0"; edition = "Core" } + } + dependencies = "invalid-dependencies" # string instead of object + commands = @() + } + } + + { Assert-DevSetupEnvValid $invalidDeps } | Should -Throw "*dependencies*must be a hashtable or PSCustomObject*" + } + } + + Context "Assert-CommandsValid - Non-Dictionary Command Entry" { + It "Should throw when array contains non-dictionary command entry" { + # This targets line 191: throw "Each command entry must be a hashtable or PSCustomObject." + # Pass an array containing a non-dictionary item + $invalidCommands = @("invalid-string-entry") # Array with string, not dictionary + + { Assert-CommandsValid -Commands $invalidCommands -Context "TestCommands" } | + Should -Throw "*Each command entry must be a hashtable or PSCustomObject*" + } + + It "Should throw when array contains number entry (not dictionary-like)" { + # Another test for line 191 with different invalid type + $invalidCommands = @(123) # Array with number, not dictionary-like + + { Assert-CommandsValid -Commands $invalidCommands -Context "TestCommands" } | + Should -Throw "*Each command entry must be a hashtable or PSCustomObject*" + } + } + + Context "Assert-DependenciesValid - Edge Case Analysis" { + It "Should work with standard hashtable dependencies" { + # Test with standard hashtable (should work normally and hit the hashtable branch) + $validDependencies = @{ + chocolatey = @{ + packages = @( + @{ name = "git"; version = "latest" } + ) + } + } + + { Assert-DependenciesValid -Dependencies $validDependencies -Context "TestDeps" } | + Should -Not -Throw + } + + It "Should work with PSCustomObject dependencies" { + # Test with PSCustomObject (should hit the PSCustomObject branch) + $validDependencies = [PSCustomObject]@{ + chocolatey = [PSCustomObject]@{ + packages = @( + [PSCustomObject]@{ name = "git"; version = "latest" } + ) + } + } + + { Assert-DependenciesValid -Dependencies $validDependencies -Context "TestDeps" } | + Should -Not -Throw + } + + It "Should handle empty hashtable dependencies" { + # Test with empty hashtable - should reach the hashtable branch and have no managers + $emptyDependencies = @{} + + { Assert-DependenciesValid -Dependencies $emptyDependencies -Context "TestDeps" } | + Should -Not -Throw + } + + It "Should document the theoretical edge case for line 469" { + # Line 469 (@() assignment) represents a theoretical edge case where: + # 1. Test-DictionaryLike returns true (object appears dictionary-like) + # 2. Object fails both -is [hashtable] and -is [PSCustomObject] checks + # + # This could theoretically happen with: + # - Custom objects with spoofed type names + # - COM objects that masquerade as dictionary-like + # - Exotic .NET types that inherit dictionary behavior but aren't standard types + # + # In practice, this edge case is extremely rare and may be unreachable + # with current PowerShell type system behavior. + + # For now, we document this as a known edge case + $true | Should -Be $true # Placeholder test to document the edge case + } + } + + Context "Assert-PackageManagerValid - PowerShell Manager Coverage" { + It "Should throw when PowerShell manager has no packages, modules, or buckets" { + # This targets line 302: PowerShell manager with valid scope but no arrays + # Should throw "Manager 'powershell' must contain at least one of: 'packages', 'modules', or 'buckets'." + + $powershellManagerWithNoArrays = @{ + scope = "CurrentUser" # Valid scope + # Missing packages, modules, and buckets arrays + } + + { Assert-PackageManagerValid -ManagerName "powershell" -ManagerData $powershellManagerWithNoArrays } | + Should -Throw "*Manager 'powershell' must contain at least one of: 'packages', 'modules', or 'buckets'*" + } + + It "Should throw when Scoop manager has no packages, modules, or buckets" { + # This targets line 320: Scoop manager with no arrays + # Should throw "Manager 'scoop' must contain at least one of: 'packages', 'modules', or 'buckets'." + + $scoopManagerWithNoArrays = @{ + # Missing packages, modules, and buckets arrays + } + + { Assert-PackageManagerValid -ManagerName "scoop" -ManagerData $scoopManagerWithNoArrays } | + Should -Throw "*Manager 'scoop' must contain at least one of: 'packages', 'modules', or 'buckets'*" + } + + It "Should throw when Homebrew manager has no packages, modules, or buckets" { + # This targets line 338: Homebrew manager with no arrays + # Should throw "Manager 'homebrew' must contain at least one of: 'packages', 'modules', or 'buckets'." + + $homebrewManagerWithNoArrays = @{ + # Missing packages, modules, and buckets arrays + } + + { Assert-PackageManagerValid -ManagerName "homebrew" -ManagerData $homebrewManagerWithNoArrays } | + Should -Throw "*Manager 'homebrew' must contain at least one of: 'packages', 'modules', or 'buckets'*" + } + } +} diff --git a/DevSetup/Private/Utils/Assert-DevSetupEnvValid.ps1 b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.ps1 new file mode 100644 index 0000000..b8916ff --- /dev/null +++ b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.ps1 @@ -0,0 +1,598 @@ +# Helper functions for working with YAML-parsed data structures +function Test-DictionaryLike { + param($obj) + return ($obj -is [hashtable] -or $obj.GetType().Name -eq 'Hashtable') -or + ($obj -is [PSCustomObject] -or $obj.GetType().Name -eq 'PSCustomObject') -or + ($obj -is [System.Collections.Specialized.OrderedDictionary] -or $obj.GetType().Name -eq 'OrderedDictionary') +} + +function Test-KeyExists { + param($obj, $key) + if ($obj -is [hashtable]) { + return $obj.ContainsKey($key) + } elseif ($obj -is [System.Collections.Specialized.OrderedDictionary]) { + return $obj.Contains($key) + } elseif ($obj -is [PSCustomObject]) { + return [bool]($obj.PSObject.Properties.Name -contains $key) + } + return $false +} + +function Get-Value { + param($obj, $key) + if ($obj -is [hashtable] -or $obj -is [System.Collections.Specialized.OrderedDictionary]) { + $value = $obj[$key] + # Preserve arrays and Lists using unary comma + if ($value -is [array] -or $value -is [System.Collections.Generic.List[System.Object]]) { + return ,$value + } + return $value + } elseif ($obj -is [PSCustomObject]) { + $value = $obj.$key + # Preserve arrays and Lists using unary comma + if ($value -is [array] -or $value -is [System.Collections.Generic.List[System.Object]]) { + return ,$value + } + return $value + } + return $null +} + +function ConvertTo-NormalizedArray { + <# + .SYNOPSIS + Converts various YAML parsing artifacts to a normalized PowerShell array + + .DESCRIPTION + YAML parsing can result in PSCustomObjects with numeric properties, hashtables with numeric keys, + or Lists. This function normalizes all these formats to a standard PowerShell array. + #> + param( + $InputObject, + [string]$Context = "array" + ) + + # Handle null input + if ($null -eq $InputObject) { + return ,@() # Use unary comma to prevent empty array unwrapping + } + + # Handle already normalized arrays first + if ($InputObject -is [array]) { + Write-Debug "ConvertTo-NormalizedArray: Input is already an array, returning as-is" + return ,$InputObject # Use unary comma to prevent array unwrapping + } + + # Handle PSCustomObject with numeric properties (YAML array artifact) + if ($InputObject -is [PSCustomObject]) { + $properties = $InputObject.PSObject.Properties.Name + $numericProperties = $properties | Where-Object { $_ -match '^\d+$' } + if ($numericProperties.Count -eq $properties.Count -and $properties.Count -gt 0) { + # Convert PSCustomObject with numeric properties to array + $result = @($properties | Sort-Object { [int]$_ } | ForEach-Object { $InputObject.$_ }) + return ,$result # Use unary comma to prevent array unwrapping + } + else { + # Single PSCustomObject item - wrap in array + return ,@($InputObject) + } + } + + # Handle hashtable with numeric keys (YAML array artifact) + elseif ($InputObject -is [hashtable]) { + $keys = $InputObject.Keys + $numericKeys = $keys | Where-Object { $_ -match '^\d+$' } + if ($numericKeys.Count -eq $keys.Count -and $keys.Count -gt 0) { + # Convert hashtable with numeric keys to array + $result = @($keys | Sort-Object { [int]$_ } | ForEach-Object { $InputObject[$_] }) + return ,$result # Use unary comma to prevent array unwrapping + } + else { + # Single hashtable item - wrap in array + return ,@($InputObject) + } + } + + # Handle List objects from YAML parsing + elseif ($InputObject -is [System.Collections.Generic.List[System.Object]]) { + $result = $InputObject.ToArray() + return ,$result # Use unary comma to prevent array unwrapping + } + + # Handle other single items - return as-is for validation to catch invalid types + else { + return $InputObject + } +} + +function Assert-ConfigurationValid { + <# + .SYNOPSIS + Validates the configuration section of a devsetup environment + #> + param( + $Configuration, + [string]$Context = "configuration" + ) + + if (-not (Test-DictionaryLike $Configuration)) { + throw "'$Context' must be a hashtable or PSCustomObject." + } + + # Required configuration fields (must be present, can be empty or null) + $requiredConfigFields = @('createdBy', 'description', 'lastModified', 'createdDate', 'version') + foreach ($field in $requiredConfigFields) { + if (-not (Test-KeyExists $Configuration $field)) { + throw "$Context must contain '$field' key." + } + $value = Get-Value $Configuration $field + if ($null -ne $value -and -not ($value -is [string])) { + throw "$Context '$field' must be a string or null." + } + } + + # OS information - must be present + if (-not (Test-KeyExists $Configuration 'os')) { + throw "$Context must contain 'os' key." + } + $os = Get-Value $Configuration 'os' + if (-not (Test-DictionaryLike $os)) { + throw "$Context 'os' must be a hashtable or PSCustomObject." + } + + $osFields = @('architecture', 'name', 'version') + foreach ($field in $osFields) { + if (-not (Test-KeyExists $os $field)) { + throw "$Context 'os' must contain '$field' key." + } + $value = Get-Value $os $field + if ($null -ne $value -and -not ($value -is [string])) { + throw "$Context 'os.$field' must be a string or null." + } + } + + # PowerShell information - must be present + if (-not (Test-KeyExists $Configuration 'powershell')) { + throw "$Context must contain 'powershell' key." + } + $ps = Get-Value $Configuration 'powershell' + if (-not (Test-DictionaryLike $ps)) { + throw "$Context 'powershell' must be a hashtable or PSCustomObject." + } + + $psFields = @('version', 'edition') + foreach ($field in $psFields) { + if (-not (Test-KeyExists $ps $field)) { + throw "$Context 'powershell' must contain '$field' key." + } + $value = Get-Value $ps $field + if (-not ($value -is [string])) { + throw "$Context 'powershell.$field' must be a string." + } + } +} + +function Assert-CommandsValid { + <# + .SYNOPSIS + Validates the commands section of a devsetup environment + #> + param( + $Commands, + [string]$Context = "commands" + ) + + # Normalize commands to array format + $normalizedCommands = ConvertTo-NormalizedArray $Commands $Context + + # Validate array type + if (-not ($normalizedCommands -is [array])) { + throw "'$Context' must be an array." + } + + foreach ($command in $normalizedCommands) { + if (-not (Test-DictionaryLike $command)) { + throw "Each command entry must be a hashtable or PSCustomObject." + } + + # Validate required command fields + if (-not (Test-KeyExists $command 'command')) { + throw "Each command entry must contain 'command' key." + } + $cmdValue = Get-Value $command 'command' + if (-not ($cmdValue -is [string]) -or [string]::IsNullOrWhiteSpace($cmdValue)) { + throw "'command' must be a non-empty string." + } + + # packageName is required for command identification/updates + if (-not (Test-KeyExists $command 'packageName')) { + throw "Each command entry must contain 'packageName' key." + } + $pkgValue = Get-Value $command 'packageName' + if (-not ($pkgValue -is [string]) -or [string]::IsNullOrWhiteSpace($pkgValue)) { + throw "'packageName' must be a non-empty string." + } + + # params must be present + if (-not (Test-KeyExists $command 'params')) { + throw "Each command entry must contain 'params' key." + } + + # Validate params hashtable + $params = Get-Value $command 'params' + + if (-not (Test-DictionaryLike $params)) { + throw "'params' must be a hashtable or PSCustomObject." + } + + # Validate each parameter value in the hashtable + $paramKeys = if ($params -is [hashtable] -or $params -is [System.Collections.Specialized.OrderedDictionary]) { + $params.Keys + } elseif ($params -is [PSCustomObject]) { + $params.PSObject.Properties.Name + } + + foreach ($key in $paramKeys) { + $value = Get-Value $params $key + if ($value -and -not ($value -is [string])) { + throw "Each parameter value in 'params' hashtable must be a string or null." + } + } + } +} + +function Assert-PackageManagerValid { + <# + .SYNOPSIS + Validates a package manager and its associated packages/modules/buckets + #> + param( + [string]$ManagerName, + $ManagerData, + [string]$Context = "package manager" + ) + + if (-not (Test-DictionaryLike $ManagerData)) { + throw "Each $Context entry must be a hashtable or PSCustomObject." + } + + # Validate manager-specific structure based on canonical New-DevSetupEnvFile structure + switch ($ManagerName) { + 'chocolatey' { + # Chocolatey should have packages array for proper structure + $arrayTypes = @('packages', 'modules', 'buckets') + $foundArrays = @() + + foreach ($arrayType in $arrayTypes) { + if (Test-KeyExists $ManagerData $arrayType) { + $foundArrays += $arrayType + $items = Get-Value $ManagerData $arrayType + Assert-PackageArrayValid -ManagerName $ManagerName -ArrayType $arrayType -Items $items + } + } + + # Ensure at least one array type is present + if ($foundArrays.Count -eq 0) { + throw "Manager '$ManagerName' must contain at least one of: 'packages', 'modules', or 'buckets'." + } + } + 'powershell' { + # PowerShell requires scope when present + if (-not (Test-KeyExists $ManagerData 'scope')) { + throw "PowerShell manager must contain 'scope' key." + } + $scopeValue = Get-Value $ManagerData 'scope' + if (-not ($scopeValue -is [string])) { + throw "PowerShell manager 'scope' must be a string." + } + # PowerShell should have at least modules + $arrayTypes = @('packages', 'modules', 'buckets') + $foundArrays = @() + + foreach ($arrayType in $arrayTypes) { + if (Test-KeyExists $ManagerData $arrayType) { + $foundArrays += $arrayType + $items = Get-Value $ManagerData $arrayType + Assert-PackageArrayValid -ManagerName $ManagerName -ArrayType $arrayType -Items $items + } + } + + # Ensure at least one array type is present + if ($foundArrays.Count -eq 0) { + throw "Manager '$ManagerName' must contain at least one of: 'packages', 'modules', or 'buckets'." + } + } + 'scoop' { + # Scoop should have at least packages or buckets + $arrayTypes = @('packages', 'modules', 'buckets') + $foundArrays = @() + + foreach ($arrayType in $arrayTypes) { + if (Test-KeyExists $ManagerData $arrayType) { + $foundArrays += $arrayType + $items = Get-Value $ManagerData $arrayType + Assert-PackageArrayValid -ManagerName $ManagerName -ArrayType $arrayType -Items $items + } + } + + # Ensure at least one array type is present + if ($foundArrays.Count -eq 0) { + throw "Manager '$ManagerName' must contain at least one of: 'packages', 'modules', or 'buckets'." + } + } + 'homebrew' { + # Homebrew should have at least packages + $arrayTypes = @('packages', 'modules', 'buckets') + $foundArrays = @() + + foreach ($arrayType in $arrayTypes) { + if (Test-KeyExists $ManagerData $arrayType) { + $foundArrays += $arrayType + $items = Get-Value $ManagerData $arrayType + Assert-PackageArrayValid -ManagerName $ManagerName -ArrayType $arrayType -Items $items + } + } + + # Ensure at least one array type is present + if ($foundArrays.Count -eq 0) { + throw "Manager '$ManagerName' must contain at least one of: 'packages', 'modules', or 'buckets'." + } + } + default { + # For any other managers, ensure they have at least one array type + $arrayTypes = @('packages', 'modules', 'buckets') + $foundArrays = @() + + foreach ($arrayType in $arrayTypes) { + if (Test-KeyExists $ManagerData $arrayType) { + $foundArrays += $arrayType + $items = Get-Value $ManagerData $arrayType + Assert-PackageArrayValid -ManagerName $ManagerName -ArrayType $arrayType -Items $items + } + } + + # Ensure at least one array type is present + if ($foundArrays.Count -eq 0) { + throw "Manager '$ManagerName' must contain at least one of: 'packages', 'modules', or 'buckets'." + } + } + } +} + +function Assert-PackageArrayValid { + <# + .SYNOPSIS + Validates an array of packages, modules, or buckets for a specific package manager + #> + param( + [string]$ManagerName, + [string]$ArrayType, + $Items + ) + + # Normalize items to array format + $normalizedItems = ConvertTo-NormalizedArray $Items "$ArrayType for manager '$ManagerName'" + + if (-not ($normalizedItems -is [array])) { + throw "'$ArrayType' for manager '$ManagerName' must be an array." + } + + foreach ($item in $normalizedItems) { + Assert-PackageItemValid -ManagerName $ManagerName -ArrayType $ArrayType -Item $item + } +} + +function Assert-PackageItemValid { + <# + .SYNOPSIS + Validates a single package, module, or bucket item + #> + param( + [string]$ManagerName, + [string]$ArrayType, + $Item + ) + + if (-not (Test-DictionaryLike $Item)) { + throw "Each $ArrayType entry for manager '$ManagerName' must be a hashtable or PSCustomObject." + } + + # Name is always required + if (-not (Test-KeyExists $Item 'name')) { + throw "Each $ArrayType entry for manager '$ManagerName' must contain 'name' key." + } + $nameValue = Get-Value $Item 'name' + if (-not ($nameValue -is [string]) -or [string]::IsNullOrWhiteSpace($nameValue)) { + throw "'name' for $ArrayType entry must be a non-empty string." + } + + # Manager and array type specific validation + switch ("$ManagerName-$ArrayType") { + 'powershell-modules' { + Assert-PowerShellModuleValid $Item + } + 'scoop-packages' { + Assert-ScoopPackageValid $Item + } + default { + if ($ArrayType -eq 'packages') { + Assert-GenericPackageValid $Item $ManagerName + } elseif ($ArrayType -eq 'buckets') { + Assert-BucketValid $Item $ManagerName + } + } + } + + # Common version validation + Assert-VersionFieldsValid $Item $ArrayType +} + +function Assert-PowerShellModuleValid { + param($Item) + + # PowerShell modules require specific fields + $requiredFields = @('version', 'minimumVersion', 'scope') + foreach ($field in $requiredFields) { + if (-not (Test-KeyExists $Item $field)) { + throw "PowerShell module must contain '$field' key." + } + $fieldValue = Get-Value $Item $field + if (-not ($fieldValue -is [string])) { + throw "PowerShell module '$field' must be a string." + } + } +} + +function Assert-ScoopPackageValid { + param($Item) + + # Scoop packages require bucket field + if (-not (Test-KeyExists $Item 'bucket')) { + throw "Scoop package must contain 'bucket' key." + } + $bucketValue = Get-Value $Item 'bucket' + if (-not ($bucketValue -is [string])) { + throw "Scoop package 'bucket' must be a string." + } +} + +function Assert-GenericPackageValid { + param($Item, $ManagerName) + + # All packages require version field + if (-not (Test-KeyExists $Item 'version')) { + throw "$ManagerName package must contain 'version' key." + } + + $versionValue = Get-Value $Item 'version' + # Handle null version (treat as empty string) + if ($null -eq $versionValue) { + $versionValue = "" + } + if (-not ($versionValue -is [string])) { + throw "$ManagerName package 'version' must be a string." + } + + # minimumVersion is optional for packages + if (Test-KeyExists $Item 'minimumVersion') { + $minVersionValue = Get-Value $Item 'minimumVersion' + # Handle null minimumVersion (treat as empty string) + if ($null -eq $minVersionValue) { + $minVersionValue = "" + } + if (-not ($minVersionValue -is [string])) { + throw "$ManagerName package 'minimumVersion' must be a string." + } + } +} + +function Assert-BucketValid { + param($Item, $ManagerName) + + # Buckets require source field + if (-not (Test-KeyExists $Item 'source')) { + throw "$ManagerName bucket must contain 'source' key." + } + $sourceValue = Get-Value $Item 'source' + if (-not ($sourceValue -is [string])) { + throw "$ManagerName bucket 'source' must be a string." + } +} + +function Assert-VersionFieldsValid { + param($Item, $Context) + + $hasVersion = Test-KeyExists $Item 'version' + $hasMinimumVersion = Test-KeyExists $Item 'minimumVersion' + + if ($hasVersion -and $hasMinimumVersion) { + $versionValue = Get-Value $Item 'version' + $minVersionValue = Get-Value $Item 'minimumVersion' + + # Handle null values + if ($null -eq $versionValue) { $versionValue = "" } + if ($null -eq $minVersionValue) { $minVersionValue = "" } + + # Both must be strings + if ($versionValue -and -not ($versionValue -is [string])) { + throw "'version' for $Context entry must be a string." + } + if ($minVersionValue -and -not ($minVersionValue -is [string])) { + throw "'minimumVersion' for $Context entry must be a string." + } + + # Cannot have both with non-empty values + if (-not [string]::IsNullOrWhiteSpace($versionValue) -and -not [string]::IsNullOrWhiteSpace($minVersionValue)) { + throw "Cannot specify both 'version' and 'minimumVersion' with values for $Context entry. Use only one." + } + } +} + +function Assert-DependenciesValid { + <# + .SYNOPSIS + Validates the dependencies section of a devsetup environment + #> + param( + $Dependencies, + [string]$Context = "dependencies" + ) + + if (-not (Test-DictionaryLike $Dependencies)) { + throw "'$Context' must be a hashtable or PSCustomObject." + } + + # Get all manager names - handle hashtable, OrderedDictionary and PSCustomObject + $managerNames = if ($Dependencies -is [hashtable] -or $Dependencies -is [System.Collections.Specialized.OrderedDictionary]) { + $Dependencies.Keys + } elseif ($Dependencies -is [PSCustomObject]) { + $Dependencies.PSObject.Properties.Name + } + + foreach ($manager in $managerNames) { + $managerData = Get-Value $Dependencies $manager + Assert-PackageManagerValid -ManagerName $manager -ManagerData $managerData + } +} + +Function Assert-DevSetupEnvValid { + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true)] + $EnvData # Accept both hashtable and PSCustomObject + ) + + # Validate root structure + if (-not (Test-DictionaryLike $EnvData)) { + throw "Environment data must be a hashtable or PSCustomObject." + } + + if (-not (Test-KeyExists $EnvData 'devsetup')) { + throw "Environment data must contain 'devsetup' key." + } + + $devsetup = Get-Value $EnvData 'devsetup' + if (-not (Test-DictionaryLike $devsetup)) { + throw "'devsetup' must be a hashtable or PSCustomObject." + } + + # Validate required top-level sections + $requiredSections = @('configuration', 'dependencies', 'commands') + foreach ($section in $requiredSections) { + if (-not (Test-KeyExists $devsetup $section)) { + throw "Environment data 'devsetup' section must contain '$section' key." + } + } + + # Validate each section using specialized functions + $config = Get-Value $devsetup 'configuration' + Assert-ConfigurationValid $config + + $dependencies = Get-Value $devsetup 'dependencies' + Assert-DependenciesValid $dependencies + + $commands = Get-Value $devsetup 'commands' + Assert-CommandsValid $commands + + return $true +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Find-GitRepositories.ps1 b/DevSetup/Private/Utils/Find-GitRepositories.ps1 deleted file mode 100644 index f7af8b1..0000000 --- a/DevSetup/Private/Utils/Find-GitRepositories.ps1 +++ /dev/null @@ -1,121 +0,0 @@ -๏ปฟFunction Find-GitRepository { - [CmdletBinding()] - Param( - [Parameter( - Position = 0, - HelpMessage = "The top level path to search" - )] - [ValidateScript({ - if (Test-Path $_) { - $True - } - else { - Throw "Cannot validate path $_" - } - })] - [string]$Path = "." - ) - - Write-Verbose "[BEGIN ] Starting: $($MyInvocation.Mycommand)" - Write-Verbose "[PROCESS] Searching $(Convert-Path -path $path) for Git repositories" - - # Define directories to exclude from search (just the folder names) - $ExcludeFolders = @('Windows', 'Program Files', 'Program Files (x86)', '$RECYCLE.BIN') - - Write-Verbose "[PROCESS] Excluding system folders: $($ExcludeFolders -join ', ')" - - # Use a more efficient search strategy - function Search-GitRepo { - param([string]$SearchPath, [string[]]$ExcludeFolders) - - try { - # Get all directories first, excluding system folders at the top level - $directories = Get-ChildItem -Path $SearchPath -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notin $ExcludeFolders } - - foreach ($dir in $directories) { - # Check if this directory IS a git repo - $gitDir = Join-Path $dir.FullName ".git" - if (Test-Path $gitDir) { - # Found a git repo, yield it - Get-Item $gitDir -Force -ErrorAction SilentlyContinue - } - - # Recursively search subdirectories (but don't exclude here since we're deeper) - Search-GitRepos -SearchPath $dir.FullName -ExcludeFolders @() - } - } - catch { - # Silently continue on errors - } - } - - # Collect all repositories in an array - $repositories = @() - - Search-GitRepos -SearchPath $Path -ExcludeFolders $ExcludeFolders | - ForEach-Object { - $gitItem = $_ - $repoPath = Split-Path $gitItem.FullName -Parent - Write-Verbose "Found repository at: $repoPath" - - # Get the branch information - $branchName = "unknown" - $remoteUrl = "none" - if ($repoPath -and (Test-Path $repoPath)) { - $originalLocation = Get-Location - try { - Write-Verbose "Changing to repository: $repoPath" - Set-Location -Path $repoPath - - # Get current branch - $branchOutput = & git rev-parse --abbrev-ref HEAD 2>$null - if ($LASTEXITCODE -eq 0 -and $branchOutput) { - $branchName = $branchOutput.Trim() - } - - # Get remote origin URL - $remoteOutput = & git remote get-url origin 2>$null - if ($LASTEXITCODE -eq 0 -and $remoteOutput) { - $remoteUrl = $remoteOutput.Trim() - } - } - catch { - Write-Verbose "Branch/Remote detection error for $repoPath`: $_" - $branchName = "error" - $remoteUrl = "error" - } - finally { - Set-Location -Path $originalLocation - } - } else { - Write-Verbose "Invalid repository path: '$repoPath'" - $branchName = "invalid-path" - $remoteUrl = "invalid-path" - } - - # Add to repositories collection - $repositories += [PSCustomObject]@{ - Repository = $repoPath - Branch = $branchName - RemoteUrl = $remoteUrl - } - } - - # Output formatted table - if ($repositories.Count -gt 0) { - Write-Host "`nFound $($repositories.Count) Git repositories:" -ForegroundColor Green - Write-Host "=" * 80 -ForegroundColor Gray - - $repositories | Sort-Object Repository | Format-Table -AutoSize -Wrap @( - @{Label="Repository"; Expression={$_.Repository}; Width=40}, - @{Label="Branch"; Expression={$_.Branch}; Width=20}, - @{Label="Remote URL"; Expression={$_.RemoteUrl}; Width=50} - ) - } else { - Write-Host "No Git repositories found in the specified path." -ForegroundColor Yellow - } - - Write-Verbose "[END ] Ending: $($MyInvocation.Mycommand)" - -} #end function \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-CenterText.Tests.ps1 b/DevSetup/Private/Utils/Format-CenterText.Tests.ps1 new file mode 100644 index 0000000..48928a6 --- /dev/null +++ b/DevSetup/Private/Utils/Format-CenterText.Tests.ps1 @@ -0,0 +1,284 @@ +BeforeAll { + . $PSScriptRoot\Format-CenterText.ps1 +} + +Describe "Format-CenterText" { + + Context "When centering text within specified width" { + It "Should center text with equal padding on both sides for even padding" { + $result = Format-CenterText -Text "Hello" -Width 11 + $result | Should -Be " Hello " + $result.Length | Should -Be 11 + } + + It "Should center text with left padding one less than right for odd padding" { + $result = Format-CenterText -Text "Hello" -Width 12 + $result | Should -Be " Hello " + $result.Length | Should -Be 12 + } + + It "Should center single character text" { + $result = Format-CenterText -Text "X" -Width 5 + $result | Should -Be " X " + $result.Length | Should -Be 5 + } + + It "Should center text with minimum width" { + $result = Format-CenterText -Text " " -Width 6 + $result | Should -Be " " + $result.Length | Should -Be 6 + } + + It "Should center text with width of 1" { + $result = Format-CenterText -Text "A" -Width 1 + $result | Should -Be "A" + $result.Length | Should -Be 1 + } + } + + Context "When text width equals specified width" { + It "Should return text unchanged when lengths are equal" { + $text = "Hello" + $result = Format-CenterText -Text $text -Width 5 + $result | Should -Be $text + $result.Length | Should -Be 5 + } + + It "Should return long text unchanged when lengths are equal" { + $text = "This is a test" + $result = Format-CenterText -Text $text -Width 14 + $result | Should -Be $text + $result.Length | Should -Be 14 + } + } + + Context "When text width exceeds specified width" { + It "Should return text unchanged when text is longer than width" { + $text = "This text is too long" + $result = Format-CenterText -Text $text -Width 10 + $result | Should -Be $text + $result.Length | Should -Be 21 + } + + It "Should return text unchanged when width is 0" { + $text = "Hello" + $result = Format-CenterText -Text $text -Width 0 + $result | Should -Be $text + } + + It "Should return text unchanged when width is negative" { + $text = "Hello" + $result = Format-CenterText -Text $text -Width -5 + $result | Should -Be $text + } + } + + Context "When handling special characters and unicode" { + It "Should center text with spaces" { + $result = Format-CenterText -Text "Hello World" -Width 21 + $result | Should -Be " Hello World " + $result.Length | Should -Be 21 + } + + It "Should center text with tabs" { + $text = "`tTab`t" + $result = Format-CenterText -Text $text -Width 10 + $result.Length | Should -Be 10 + $result | Should -Match "Tab" + } + + It "Should center text with newlines" { + $text = "Line1`nLine2" + $result = Format-CenterText -Text $text -Width 20 + $result.Length | Should -Be 20 + $result | Should -Match "Line1" + $result | Should -Match "Line2" + } + + It "Should handle unicode characters" { + $result = Format-CenterText -Text "Hรฉllo" -Width 11 + $result | Should -Be " Hรฉllo " + $result.Length | Should -Be 11 + } + + It "Should handle special symbols" { + $result = Format-CenterText -Text "A*B" -Width 9 + $result | Should -Be " A*B " + $result.Length | Should -Be 9 + } + } + + Context "When handling different data types" { + It "Should convert numbers to strings and center them" { + $result = Format-CenterText -Text 123 -Width 7 + $result | Should -Be " 123 " + $result.Length | Should -Be 7 + } + + It "Should convert boolean to strings and center them" { + $result = Format-CenterText -Text $true -Width 8 + $result | Should -Be " True " + $result.Length | Should -Be 8 + } + + It "Should handle string values that are effectively empty" { + $result = Format-CenterText -Text " " -Width 6 + $result | Should -Be " " + $result.Length | Should -Be 6 + } + + It "Should handle objects by converting to string representation" { + $obj = [PSCustomObject]@{ Name = "Test" } + $result = Format-CenterText -Text $obj -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "Name=Test" + } + } + + Context "When testing edge cases and boundary conditions" { + It "Should handle very large width values" { + $result = Format-CenterText -Text "Hi" -Width 1000 + $result.Length | Should -Be 1000 + $result | Should -Match "^\s{499}Hi\s{499}$" + } + + It "Should handle width of exactly text length plus 1" { + $result = Format-CenterText -Text "Test" -Width 5 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle width of exactly text length plus 2" { + $result = Format-CenterText -Text "Test" -Width 6 + $result | Should -Be " Test " + $result.Length | Should -Be 6 + } + } + + Context "When testing mathematical calculations" { + It "Should correctly calculate left padding for odd total padding" { + # Text = "ABC" (3 chars), Width = 10, Padding = 7, Left = 3, Right = 4 + $result = Format-CenterText -Text "ABC" -Width 10 + $leftSpaces = ($result -split 'ABC')[0].Length + $rightSpaces = ($result -split 'ABC')[1].Length + $leftSpaces | Should -Be 3 + $rightSpaces | Should -Be 4 + $result | Should -Be " ABC " + } + + It "Should correctly calculate left padding for even total padding" { + # Text = "AB" (2 chars), Width = 8, Padding = 6, Left = 3, Right = 3 + $result = Format-CenterText -Text "AB" -Width 8 + $leftSpaces = ($result -split 'AB')[0].Length + $rightSpaces = ($result -split 'AB')[1].Length + $leftSpaces | Should -Be 3 + $rightSpaces | Should -Be 3 + $result | Should -Be " AB " + } + + It "Should use Math.Floor for left padding calculation" { + # Verify that left padding uses floor (truncates decimals) + # Text = "X" (1 char), Width = 4, Padding = 3, Left = Floor(1.5) = 1, Right = 2 + $result = Format-CenterText -Text "X" -Width 4 + $leftSpaces = ($result -split 'X')[0].Length + $rightSpaces = ($result -split 'X')[1].Length + $leftSpaces | Should -Be 1 + $rightSpaces | Should -Be 2 + $result | Should -Be " X " + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Format-CenterText -Text "Windows" -Width 15 + $result | Should -Be " Windows " + $result.Length | Should -Be 15 + } + + It "Should work on Linux" { + $result = Format-CenterText -Text "Linux" -Width 13 + $result | Should -Be " Linux " + $result.Length | Should -Be 13 + } + + It "Should work on macOS" { + $result = Format-CenterText -Text "macOS" -Width 11 + $result | Should -Be " macOS " + $result.Length | Should -Be 11 + } + } + + Context "PowerShell 5.1 compatibility" { + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Format-CenterText.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible Math.Floor method" { + # Test that Math.Floor works in PS 5.1 + { Format-CenterText -Text "Test" -Width 10 } | Should -Not -Throw + } + + It "Should use compatible string operations" { + # Test string multiplication and concatenation work in PS 5.1 + $result = Format-CenterText -Text "PS5.1" -Width 15 + $result | Should -Be " PS5.1 " + $result.Length | Should -Be 15 + } + + It "Should work with older .NET Framework string handling" { + # Test that string operations work with .NET Framework 4.x + $result = Format-CenterText -Text "Framework" -Width 17 + $result | Should -Be " Framework " + $result.Length | Should -Be 17 + } + } + + Context "Performance and stress testing" { + It "Should handle multiple consecutive calls efficiently" { + $results = @() + for ($i = 1; $i -le 100; $i++) { + $results += Format-CenterText -Text "Item$i" -Width 20 + } + $results.Count | Should -Be 100 + $results[0] | Should -Match "Item1" + $results[99] | Should -Match "Item100" + } + + It "Should handle very long text efficiently" { + $longText = "A" * 1000 + $result = Format-CenterText -Text $longText -Width 500 + $result | Should -Be $longText # Should return unchanged since text > width + $result.Length | Should -Be 1000 + } + + It "Should handle repeated characters" { + $result = Format-CenterText -Text ("X" * 5) -Width 15 + $result | Should -Be " XXXXX " + $result.Length | Should -Be 15 + } + } + + Context "Parameter validation and error handling" { + It "Should accept mandatory Text parameter" { + { Format-CenterText -Text "Required" -Width 10 } | Should -Not -Throw + } + + It "Should accept mandatory Width parameter" { + { Format-CenterText -Text "Test" -Width 5 } | Should -Not -Throw + } + + It "Should handle zero width gracefully" { + $result = Format-CenterText -Text "Test" -Width 0 + $result | Should -Be "Test" + } + + It "Should handle extremely large width values" { + # Test with large but reasonable width (avoid memory issues in test environment) + $result = Format-CenterText -Text "Big" -Width 100 + $result.Length | Should -Be 100 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-CenterText.ps1 b/DevSetup/Private/Utils/Format-CenterText.ps1 new file mode 100644 index 0000000..e84e865 --- /dev/null +++ b/DevSetup/Private/Utils/Format-CenterText.ps1 @@ -0,0 +1,17 @@ +Function Format-CenterText { + param( + [Parameter(Mandatory=$true)] + [string]$Text, + [Parameter(Mandatory=$true)] + [int]$Width + ) + + $Text = "$Text" + $Pad = $Width - $Text.Length + if ($Pad -le 0) { + return $Text + } + $Left = [math]::Floor($Pad / 2) + $Right = $Pad - $Left + return (' ' * $Left) + $Text + (' ' * $Right) +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-LeftText.Tests.ps1 b/DevSetup/Private/Utils/Format-LeftText.Tests.ps1 new file mode 100644 index 0000000..0fc9c31 --- /dev/null +++ b/DevSetup/Private/Utils/Format-LeftText.Tests.ps1 @@ -0,0 +1,368 @@ +BeforeAll { + . $PSScriptRoot\Format-LeftText.ps1 +} + +Describe "Format-LeftText" { + + Context "When left-aligning text within specified width" { + It "Should left-align text with leading space and trailing spaces" { + $result = Format-LeftText -Text "Hello" -Width 10 + $result | Should -Be " Hello " + $result.Length | Should -Be 10 + } + + It "Should left-align single character text" { + $result = Format-LeftText -Text "X" -Width 5 + $result | Should -Be " X " + $result.Length | Should -Be 5 + } + + It "Should left-align text with minimum width" { + $result = Format-LeftText -Text "Hi" -Width 4 + $result | Should -Be " Hi " + $result.Length | Should -Be 4 + } + + It "Should handle width of exactly text length plus 1" { + $result = Format-LeftText -Text "Test" -Width 5 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should handle width of exactly text length plus 2" { + $result = Format-LeftText -Text "Test" -Width 6 + $result | Should -Be " Test " + $result.Length | Should -Be 6 + } + } + + Context "When text width equals or exceeds specified width" { + It "Should return text with leading space when formatted text equals width" { + $result = Format-LeftText -Text "Test" -Width 5 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should return text unchanged when formatted text exceeds width" { + $result = Format-LeftText -Text "This is a long text" -Width 10 + $result | Should -Be " This is a long text" + $result.Length | Should -Be 20 # Original length + 1 for leading space + } + + It "Should return text unchanged when width is 0" { + $result = Format-LeftText -Text "Hello" -Width 0 + $result | Should -Be " Hello" + $result.Length | Should -Be 6 + } + + It "Should return text unchanged when width is negative" { + $result = Format-LeftText -Text "Hello" -Width -5 + $result | Should -Be " Hello" + $result.Length | Should -Be 6 + } + + It "Should handle very long text exceeding width" { + $longText = "A" * 50 + $result = Format-LeftText -Text $longText -Width 20 + $result | Should -Be " $longText" + $result.Length | Should -Be 51 + } + } + + Context "When handling special characters and content" { + It "Should left-align text with spaces" { + $result = Format-LeftText -Text "Hello World" -Width 20 + $result | Should -Be " Hello World " + $result.Length | Should -Be 20 + } + + It "Should handle text with tabs" { + $result = Format-LeftText -Text "Tab`tText" -Width 15 + $result | Should -Be " Tab`tText " + $result.Length | Should -Be 15 + } + + It "Should handle text with newlines" { + $result = Format-LeftText -Text "Line1`nLine2" -Width 20 + $result | Should -Be " Line1`nLine2 " + $result.Length | Should -Be 20 + } + + It "Should handle unicode characters" { + $result = Format-LeftText -Text "Hรฉllo" -Width 12 + $result | Should -Be " Hรฉllo " + $result.Length | Should -Be 12 + } + + It "Should handle text with leading spaces" { + $result = Format-LeftText -Text " Spaced" -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + + It "Should handle text with trailing spaces" { + $result = Format-LeftText -Text "Spaced " -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + } + + Context "When handling different data types" { + It "Should convert numbers to strings and left-align them" { + $result = Format-LeftText -Text 123 -Width 8 + $result | Should -Be " 123 " + $result.Length | Should -Be 8 + } + + It "Should convert boolean to strings and left-align them" { + $result = Format-LeftText -Text $true -Width 10 + $result | Should -Be " True " + $result.Length | Should -Be 10 + } + + It "Should convert zero to string and left-align it" { + $result = Format-LeftText -Text 0 -Width 6 + $result | Should -Be " 0 " + $result.Length | Should -Be 6 + } + + It "Should convert false to string and left-align it" { + $result = Format-LeftText -Text $false -Width 8 + $result | Should -Be " False " + $result.Length | Should -Be 8 + } + + It "Should handle objects by converting to string representation" { + $obj = [PSCustomObject]@{ Name = "Test" } + $result = Format-LeftText -Text $obj -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match " @{Name=Test}" + } + } + + Context "When testing edge cases and boundary conditions" { + It "Should handle very large width values" { + $result = Format-LeftText -Text "Small" -Width 100 + $result.Length | Should -Be 100 + $result | Should -Match "^ Small" + $result | Should -Match " {94}$" # 94 trailing spaces + } + + It "Should handle width of 1 with single character" { + $result = Format-LeftText -Text "A" -Width 1 + $result | Should -Be " A" + $result.Length | Should -Be 2 + } + + It "Should handle width of 2 with single character" { + $result = Format-LeftText -Text "A" -Width 2 + $result | Should -Be " A" + $result.Length | Should -Be 2 + } + + It "Should handle width of 3 with single character" { + $result = Format-LeftText -Text "A" -Width 3 + $result | Should -Be " A " + $result.Length | Should -Be 3 + } + } + + Context "When testing string manipulation behavior" { + It "Should always add exactly one leading space" { + $testCases = @( + @{ Text = "A"; Width = 10 } + @{ Text = "Hello"; Width = 10 } + @{ Text = "Very Long Text"; Width = 5 } + ) + + foreach ($case in $testCases) { + $result = Format-LeftText -Text $case.Text -Width $case.Width + $result | Should -Match "^ " # Should always start with exactly one space + $result.Substring(0, 1) | Should -Be " " + } + } + + It "Should preserve original text after leading space" { + $originalText = "Preserve This Text" + $result = Format-LeftText -Text $originalText -Width 30 + $result.Substring(1, $originalText.Length) | Should -Be $originalText + } + + It "Should pad with spaces when width is larger than formatted text" { + $result = Format-LeftText -Text "Pad" -Width 10 + $trailingPart = $result.Substring(4) # After " Pad" + $trailingPart | Should -Be " " # 6 spaces + $trailingPart.Length | Should -Be 6 + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Format-LeftText -Text "Windows" -Width 15 + $result | Should -Be " Windows " + $result.Length | Should -Be 15 + } + + It "Should work on Linux" { + $result = Format-LeftText -Text "Linux" -Width 12 + $result | Should -Be " Linux " + $result.Length | Should -Be 12 + } + + It "Should work on macOS" { + $result = Format-LeftText -Text "macOS" -Width 10 + $result | Should -Be " macOS " + $result.Length | Should -Be 10 + } + } + + Context "PowerShell 5.1 compatibility" { + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Format-LeftText.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible string operations" { + # Test string concatenation and multiplication work in PS 5.1 + $result = Format-LeftText -Text "PS5.1" -Width 15 + $result | Should -Be " PS5.1 " + $result.Length | Should -Be 15 + } + + It "Should work with older .NET Framework string handling" { + # Test that string operations work with .NET Framework 4.x + $result = Format-LeftText -Text "Framework" -Width 18 + $result | Should -Be " Framework " + $result.Length | Should -Be 18 + } + + It "Should handle string length calculations correctly" { + # Test .Length property works correctly in PS 5.1 + $text = "Test" + $result = Format-LeftText -Text $text -Width 10 + $result.Length | Should -Be 10 + ($result.Substring(1, $text.Length)) | Should -Be $text + } + } + + Context "Performance and stress testing" { + It "Should handle multiple consecutive calls efficiently" { + $results = @() + for ($i = 1; $i -le 50; $i++) { + $results += Format-LeftText -Text "Item$i" -Width 15 + } + $results.Count | Should -Be 50 + $results[0] | Should -Be " Item1 " + $results[49] | Should -Be " Item50 " + } + + It "Should handle very long text efficiently" { + $longText = "B" * 500 + $result = Format-LeftText -Text $longText -Width 100 + $result | Should -Be " $longText" + $result.Length | Should -Be 501 + } + + It "Should handle repeated characters in padding" { + $result = Format-LeftText -Text "X" -Width 20 + $result | Should -Be " X " + $result.Length | Should -Be 20 + # Verify it's actually spaces in the padding + $padding = $result.Substring(2) + $padding | Should -Match "^ {18}$" + } + + It "Should handle wide characters efficiently" { + $result = Format-LeftText -Text "Wide" -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "^ Wide {45}$" + } + } + + Context "Mathematical calculations and logic" { + It "Should calculate padding correctly for various widths" { + $testCases = @( + @{ Text = "Hi"; Width = 5; ExpectedPadding = 2 } # " Hi" (3) needs 2 more + @{ Text = "Test"; Width = 8; ExpectedPadding = 3 } # " Test" (5) needs 3 more + @{ Text = "A"; Width = 10; ExpectedPadding = 8 } # " A" (2) needs 8 more + ) + + foreach ($case in $testCases) { + $result = Format-LeftText -Text $case.Text -Width $case.Width + $paddingLength = $result.Length - (" " + $case.Text).Length + $paddingLength | Should -Be $case.ExpectedPadding + } + } + + It "Should handle boundary condition where formatted text equals width" { + $result = Format-LeftText -Text "Exact" -Width 6 + $result | Should -Be " Exact" + $result.Length | Should -Be 6 + # No additional padding should be added + } + + It "Should handle the greater-than-or-equal condition correctly" { + # Test the boundary where $Text.Length == $Width + $result = Format-LeftText -Text "12345" -Width 6 # " 12345" = 6 chars + $result | Should -Be " 12345" + $result.Length | Should -Be 6 + + # Test where $Text.Length > $Width + $result2 = Format-LeftText -Text "123456" -Width 6 # " 123456" = 7 chars > 6 + $result2 | Should -Be " 123456" + $result2.Length | Should -Be 7 + } + } + + Context "Parameter validation behavior" { + It "Should accept mandatory Text parameter" { + { Format-LeftText -Text "Required" -Width 10 } | Should -Not -Throw + } + + It "Should accept mandatory Width parameter" { + { Format-LeftText -Text "Test" -Width 5 } | Should -Not -Throw + } + + It "Should handle zero width gracefully" { + $result = Format-LeftText -Text "Test" -Width 0 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should handle negative width gracefully" { + $result = Format-LeftText -Text "Test" -Width -10 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should handle extremely large width values without error" { + # Test with large but reasonable width + $result = Format-LeftText -Text "Big" -Width 200 + $result.Length | Should -Be 200 + $result.Substring(0, 4) | Should -Be " Big" + } + } + + Context "String formatting consistency" { + It "Should maintain consistent formatting pattern" { + $testTexts = @("A", "AB", "ABC", "ABCD", "ABCDE") + $width = 10 + + foreach ($text in $testTexts) { + $result = Format-LeftText -Text $text -Width $width + $result.Length | Should -Be $width + $result | Should -Match "^ $text" + $result.Substring(0, 1) | Should -Be " " + $result.Substring(1, $text.Length) | Should -Be $text + } + } + + It "Should handle whitespace-only input" { + $result = Format-LeftText -Text " " -Width 10 + $result | Should -Be (" " + (" " * 6)) # " " + " " + 6 padding spaces = 10 total spaces + $result.Length | Should -Be 10 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-LeftText.ps1 b/DevSetup/Private/Utils/Format-LeftText.ps1 new file mode 100644 index 0000000..2150607 --- /dev/null +++ b/DevSetup/Private/Utils/Format-LeftText.ps1 @@ -0,0 +1,14 @@ +Function Format-LeftText { + param( + [Parameter(Mandatory=$true)] + [string]$Text, + [Parameter(Mandatory=$true)] + [int]$Width + ) + + $Text = " $Text" + if ($Text.Length -ge $Width) { + return $Text + } + return $Text + (' ' * ($Width - $Text.Length)) +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-PrettyTable.Tests.ps1 b/DevSetup/Private/Utils/Format-PrettyTable.Tests.ps1 new file mode 100644 index 0000000..3cc62bf --- /dev/null +++ b/DevSetup/Private/Utils/Format-PrettyTable.Tests.ps1 @@ -0,0 +1,292 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Format-PrettyTable.ps1") + . (Join-Path $PSScriptRoot "Write-StatusMessage.ps1") + Mock Write-StatusMessage { } +} + +Describe "Format-PrettyTable" { + + Context "When formatting table with left alignment" { + It "Should output table with left-aligned columns" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + Age = @{ Key = "Age"; Name = "Age"; Width = 5; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice"; Age = 30 } + @{ Name = "Bob"; Age = 25 } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 18 -Scope It # Top, header, middle, 2 rows, bottom + } + } + + Context "When formatting table with center alignment" { + It "Should output table with center-aligned columns" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Center"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It # Top, header, middle, row, bottom + } + } + + Context "When formatting table with right alignment" { + It "Should output table with right-aligned columns" { + $columns = @{ + Age = @{ Key = "Age"; Name = "Age"; Width = 5; Alignment = "Right"; Color = "White" } + } + $rows = @( + @{ Age = 30 } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When formatting table with mixed alignments" { + It "Should output table with mixed alignments" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + Age = @{ Key = "Age"; Name = "Age"; Width = 5; Alignment = "Center"; Color = "White" } + City = @{ Key = "City"; Name = "City"; Width = 8; Alignment = "Right"; Color = "White" } + } + $rows = @( + @{ Name = "Alice"; Age = 30; City = "NYC" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 17 -Scope It + } + } + + Context "When rows are objects instead of hashtables" { + It "Should handle object properties" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + [PSCustomObject]@{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + # Context "When table has no rows" { + # It "Should output only borders and header" { + # $columns = @{ + # Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + # } + # $rows = @() + # $tableFormat = @{ BorderColor = "Gray" } + # Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + # Assert-MockCalled Write-StatusMessage -Exactly 3 -Scope It # Top, header, bottom + # } + # } + + Context "When table has single column" { + It "Should output table without inner separators" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When text exceeds column width" { + It "Should truncate or handle long text" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 5; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "VeryLongName" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When table format has different border color" { + It "Should use specified border color" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Red" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When row has color" { + It "Should use row color for data" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice"; Color = "Green" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When using NoHeader table format" { + It "Should skip header output when NoHeader is true" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray"; NoHeader = $true } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 5 -Scope It # Top, row, bottom (no header/middle) + } + } + + Context "When handling null/empty text values" { + It "Should replace null/empty text with [BLANK] in center alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = ""; Width = 10; Alignment = "Center"; Color = "White" } + } + $rows = @( + @{ Name = $null } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should replace null/empty text with [BLANK] in left alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = " "; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should replace null/empty text with [BLANK] in right alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = $null; Width = 10; Alignment = "Right"; Color = "White" } + } + $rows = @( + @{ Name = " " } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When text width equals or exceeds column width" { + It "Should handle text width greater than column width in center alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = "VeryLongHeaderText"; Width = 5; Alignment = "Center"; Color = "White" } + } + $rows = @( + @{ Name = "Short" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should handle text width equal to column width in right alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Right"; Color = "White" } + } + $rows = @( + @{ Name = "TenCharsTxt" } # Exactly 10 chars + 1 space prefix = 11 chars >= 10 width + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When using default alignment" { + It "Should handle default alignment for column headers" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Unknown"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should handle default alignment for row values" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "InvalidType"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should work on Linux" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should work on macOS" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-PrettyTable.ps1 b/DevSetup/Private/Utils/Format-PrettyTable.ps1 index d897df9..f622d75 100644 --- a/DevSetup/Private/Utils/Format-PrettyTable.ps1 +++ b/DevSetup/Private/Utils/Format-PrettyTable.ps1 @@ -1,4 +1,5 @@ Function Format-PrettyTable { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] [cmdletbinding()] Param( [Parameter(Mandatory=$true)] @@ -23,13 +24,14 @@ Function Format-PrettyTable { # Light single-line for inner separators $sepV = [char]0x2502 # โ”‚ - $sepH = [char]0x2500 # โ”€ - $sepT = [char]0x252C # โ”ฌ - $sepM = [char]0x253C # โ”ผ - $sepB = [char]0x2534 # โ”ด - - function Repeat-Char($char, $count) { -join (1..$count | ForEach-Object { $char }) } - function Center-Text($text, $width) { + #$sepH = [char]0x2500 # โ”€ + #$sepT = [char]0x252C # โ”ฌ + #$sepM = [char]0x253C # โ”ผ + #$sepB = [char]0x2534 # โ”ด + + function Write-RepeatChar($char, $count) { -join (1..$count | ForEach-Object { $char }) } + function Write-CenterText($text, $width) { + if([string]::IsNullOrWhiteSpace($text)) { $text = "[BLANK]" } $text = "$text" $pad = $width - $text.Length if ($pad -le 0) { return $text } @@ -38,13 +40,15 @@ Function Format-PrettyTable { (' ' * $left) + $text + (' ' * $right) } - function Left-Text($text, $width) { + function Write-LeftText($text, $width) { + if([string]::IsNullOrWhiteSpace($text)) { $text = "[BLANK]" } $text = " $text" if ($text.Length -ge $width) { return $text } return $text + (' ' * ($width - $text.Length)) } - function Right-Text($text, $width) { + function Write-RightText($text, $width) { + if([string]::IsNullOrWhiteSpace($text)) { $text = "[BLANK]" } $text = "$text " if ($text.Length -ge $width) { return $text } return (' ' * ($width - $text.Length)) + $text @@ -57,9 +61,9 @@ Function Format-PrettyTable { $idx = 0; foreach ($column in $Columns.Values) { - $topBorder += (Repeat-Char $edgeH $column.Width) - $middleBorder += (Repeat-Char $edgeH $column.Width) - $bottomBorder += (Repeat-Char $edgeH $column.Width) + $topBorder += (Write-RepeatChar $edgeH $column.Width) + $middleBorder += (Write-RepeatChar $edgeH $column.Width) + $bottomBorder += (Write-RepeatChar $edgeH $column.Width) if ($idx -lt $Columns.Count -1) { # Add light separators @@ -74,32 +78,35 @@ Function Format-PrettyTable { $middleBorder += $edgeV $bottomBorder += $edgeBR - Write-Host $topBorder -ForegroundColor $TableFormat.BorderColor - Write-Host $edgeV -ForegroundColor $TableFormat.BorderColor -NoNewLine + if( $TableFormat.ContainsKey("NoHeader") -and $TableFormat.NoHeader -eq $true ) { + Write-StatusMessage $topBorder -ForegroundColor $TableFormat.BorderColor + } else { + Write-StatusMessage $topBorder -ForegroundColor $TableFormat.BorderColor + Write-StatusMessage $edgeV -ForegroundColor $TableFormat.BorderColor -NoNewLine - $idx = 0; - foreach ($column in $Columns.Values) { - $columnText = switch ($column.Alignment) { - "Left" { Left-Text $column.Name $column.Width } - "Center" { Center-Text $column.Name $column.Width } - "Right" { Right-Text $column.Name $column.Width } - default { $column.Name } - } + $idx = 0; + foreach ($column in $Columns.Values) { + $columnText = switch ($column.Alignment) { + "Left" { Write-LeftText $column.Name $column.Width } + "Center" { Write-CenterText $column.Name $column.Width } + "Right" { Write-RightText $column.Name $column.Width } + default { $column.Name } + } - Write-Host $columnText -ForegroundColor $column.Color -NoNewLine + Write-StatusMessage $columnText -ForegroundColor $column.Color -NoNewLine - if ($idx -lt $Columns.Count -1) { - Write-Host $sepV -ForegroundColor $TableFormat.BorderColor -NoNewLine + if ($idx -lt $Columns.Count -1) { + Write-StatusMessage $sepV -ForegroundColor $TableFormat.BorderColor -NoNewLine + } + $idx++ } - $idx++ - } - Write-Host $edgeV -ForegroundColor $TableFormat.BorderColor - - Write-Host $middleBorder -ForegroundColor $TableFormat.BorderColor + Write-StatusMessage $edgeV -ForegroundColor $TableFormat.BorderColor + Write-StatusMessage $middleBorder -ForegroundColor $TableFormat.BorderColor + } foreach ($row in $Rows) { - Write-Host $edgeV -ForegroundColor $TableFormat.BorderColor -NoNewLine + Write-StatusMessage $edgeV -ForegroundColor $TableFormat.BorderColor -NoNewLine $idx = 0; foreach ($column in $Columns.Values) { if ($row -is [hashtable]) { @@ -109,22 +116,22 @@ Function Format-PrettyTable { } $columnText = switch ($column.Alignment) { - "Left" { Left-Text $value $column.Width } - "Center" { Center-Text $value $column.Width } - "Right" { Right-Text $value $column.Width } + "Left" { Write-LeftText $value $column.Width } + "Center" { Write-CenterText $value $column.Width } + "Right" { Write-RightText $value $column.Width } default { $value } } - Write-Host $columnText -ForegroundColor $row.Color -NoNewLine + Write-StatusMessage $columnText -ForegroundColor $row.Color -NoNewLine if ($idx -lt $Columns.Count -1) { - Write-Host $sepV -ForegroundColor $TableFormat.BorderColor -NoNewLine + Write-StatusMessage $sepV -ForegroundColor $TableFormat.BorderColor -NoNewLine } $idx++ } - Write-Host $edgeV -ForegroundColor $TableFormat.BorderColor + Write-StatusMessage $edgeV -ForegroundColor $TableFormat.BorderColor } - Write-Host $bottomBorder -ForegroundColor $TableFormat.BorderColor + Write-StatusMessage $bottomBorder -ForegroundColor $TableFormat.BorderColor } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-RightText.Tests.ps1 b/DevSetup/Private/Utils/Format-RightText.Tests.ps1 new file mode 100644 index 0000000..02a49d0 --- /dev/null +++ b/DevSetup/Private/Utils/Format-RightText.Tests.ps1 @@ -0,0 +1,371 @@ +BeforeAll { + . $PSScriptRoot\Format-RightText.ps1 +} + +Describe "Format-RightText" { + + Context "When right-aligning text within specified width" { + It "Should right-align text with trailing space and leading spaces" { + $result = Format-RightText -Text "Hello" -Width 10 + $result | Should -Be " Hello " + $result.Length | Should -Be 10 + } + + It "Should right-align single character text" { + $result = Format-RightText -Text "X" -Width 5 + $result | Should -Be " X " + $result.Length | Should -Be 5 + } + + It "Should right-align text with minimum width" { + $result = Format-RightText -Text "Hi" -Width 4 + $result | Should -Be " Hi " + $result.Length | Should -Be 4 + } + + It "Should handle width of exactly text length plus 1" { + $result = Format-RightText -Text "Test" -Width 5 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle width of exactly text length plus 2" { + $result = Format-RightText -Text "Test" -Width 6 + $result | Should -Be " Test " + $result.Length | Should -Be 6 + } + } + + Context "When text width equals or exceeds specified width" { + It "Should return text with trailing space when formatted text equals width" { + $result = Format-RightText -Text "Test" -Width 5 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should return text unchanged when formatted text exceeds width" { + $result = Format-RightText -Text "This is a long text" -Width 10 + $result | Should -Be "This is a long text " + $result.Length | Should -Be 20 # Original length + 1 for trailing space + } + + It "Should return text unchanged when width is 0" { + $result = Format-RightText -Text "Hello" -Width 0 + $result | Should -Be "Hello " + $result.Length | Should -Be 6 + } + + It "Should return text unchanged when width is negative" { + $result = Format-RightText -Text "Hello" -Width -5 + $result | Should -Be "Hello " + $result.Length | Should -Be 6 + } + + It "Should handle very long text exceeding width" { + $longText = "A" * 50 + $result = Format-RightText -Text $longText -Width 20 + $result | Should -Be "$longText " + $result.Length | Should -Be 51 + } + } + + Context "When handling special characters and content" { + It "Should right-align text with spaces" { + $result = Format-RightText -Text "Hello World" -Width 20 + $result | Should -Be " Hello World " + $result.Length | Should -Be 20 + } + + It "Should handle text with tabs" { + $result = Format-RightText -Text "Tab`tText" -Width 15 + $result | Should -Be " Tab`tText " + $result.Length | Should -Be 15 + } + + It "Should handle text with newlines" { + $result = Format-RightText -Text "Line1`nLine2" -Width 20 + $result | Should -Be " Line1`nLine2 " + $result.Length | Should -Be 20 + } + + It "Should handle unicode characters" { + $result = Format-RightText -Text "Hรฉllo" -Width 12 + $result | Should -Be " Hรฉllo " + $result.Length | Should -Be 12 + } + + It "Should handle text with leading spaces" { + $result = Format-RightText -Text " Spaced" -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + + It "Should handle text with trailing spaces" { + $result = Format-RightText -Text "Spaced " -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + } + + Context "When handling different data types" { + It "Should convert numbers to strings and right-align them" { + $result = Format-RightText -Text 123 -Width 8 + $result | Should -Be " 123 " + $result.Length | Should -Be 8 + } + + It "Should convert boolean to strings and right-align them" { + $result = Format-RightText -Text $true -Width 10 + $result | Should -Be " True " + $result.Length | Should -Be 10 + } + + It "Should convert zero to string and right-align it" { + $result = Format-RightText -Text 0 -Width 6 + $result | Should -Be " 0 " + $result.Length | Should -Be 6 + } + + It "Should convert false to string and right-align it" { + $result = Format-RightText -Text $false -Width 8 + $result | Should -Be " False " + $result.Length | Should -Be 8 + } + + It "Should handle objects by converting to string representation" { + $obj = [PSCustomObject]@{ Name = "Test" } + $result = Format-RightText -Text $obj -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "@{Name=Test} " + } + } + + Context "When testing edge cases and boundary conditions" { + It "Should handle very large width values" { + $result = Format-RightText -Text "Small" -Width 100 + $result.Length | Should -Be 100 + $result | Should -Match "Small $" + $result | Should -Match "^ {94}Small $" # 94 leading spaces + } + + It "Should handle width of 1 with single character" { + $result = Format-RightText -Text "A" -Width 1 + $result | Should -Be "A " + $result.Length | Should -Be 2 + } + + It "Should handle width of 2 with single character" { + $result = Format-RightText -Text "A" -Width 2 + $result | Should -Be "A " + $result.Length | Should -Be 2 + } + + It "Should handle width of 3 with single character" { + $result = Format-RightText -Text "A" -Width 3 + $result | Should -Be " A " + $result.Length | Should -Be 3 + } + } + + Context "When testing string manipulation behavior" { + It "Should always add exactly one trailing space" { + $testCases = @( + @{ Text = "A"; Width = 5 } + @{ Text = "AB"; Width = 5 } + @{ Text = "ABC"; Width = 5 } + @{ Text = "ABCD"; Width = 5 } + @{ Text = "ABCDE"; Width = 5 } + ) + + foreach ($case in $testCases) { + $result = Format-RightText -Text $case.Text -Width $case.Width + $result | Should -Match "$($case.Text) $" + $result.Substring($result.Length - 1) | Should -Be " " + $result.Substring($result.Length - 2, 1) | Should -Be $case.Text.Substring($case.Text.Length - 1) + } + } + + It "Should preserve original text before trailing space" { + $originalText = "Preserve This Text" + $result = Format-RightText -Text $originalText -Width 30 + $result.Substring($result.Length - ($originalText.Length + 1), $originalText.Length) | Should -Be $originalText + } + + It "Should pad with spaces when width is larger than formatted text" { + $result = Format-RightText -Text "Pad" -Width 10 + $leadingPart = $result.Substring(0, 6) # Before "Pad " + $leadingPart | Should -Be " " # 6 spaces + $leadingPart.Length | Should -Be 6 + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Format-RightText -Text "Windows" -Width 15 + $result | Should -Be " Windows " + $result.Length | Should -Be 15 + } + + It "Should work on Linux" { + $result = Format-RightText -Text "Linux" -Width 12 + $result | Should -Be " Linux " + $result.Length | Should -Be 12 + } + + It "Should work on macOS" { + $result = Format-RightText -Text "macOS" -Width 10 + $result | Should -Be " macOS " + $result.Length | Should -Be 10 + } + } + + Context "PowerShell 5.1 compatibility" { + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Format-RightText.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible string operations" { + # Test string concatenation and multiplication work in PS 5.1 + $result = Format-RightText -Text "PS5.1" -Width 15 + $result | Should -Be " PS5.1 " + $result.Length | Should -Be 15 + } + + It "Should work with older .NET Framework string handling" { + # Test that string operations work with .NET Framework 4.x + $result = Format-RightText -Text "Framework" -Width 18 + $result | Should -Be " Framework " + $result.Length | Should -Be 18 + } + + It "Should handle string length calculations correctly" { + # Test .Length property works correctly in PS 5.1 + $text = "Test" + $result = Format-RightText -Text $text -Width 10 + $result.Length | Should -Be 10 + $result.Substring($result.Length - ($text.Length + 1), $text.Length) | Should -Be $text + } + } + + Context "Performance and stress testing" { + It "Should handle multiple consecutive calls efficiently" { + $results = @() + for ($i = 1; $i -le 50; $i++) { + $results += Format-RightText -Text "Item$i" -Width 15 + } + $results.Count | Should -Be 50 + $results[0] | Should -Be " Item1 " + $results[49] | Should -Be " Item50 " + } + + It "Should handle very long text efficiently" { + $longText = "B" * 500 + $result = Format-RightText -Text $longText -Width 100 + $result | Should -Be "$longText " + $result.Length | Should -Be 501 + } + + It "Should handle repeated characters in padding" { + $result = Format-RightText -Text "X" -Width 20 + $result | Should -Be " X " + $result.Length | Should -Be 20 + # Verify it's actually spaces in the padding + $padding = $result.Substring(0, 18) + $padding | Should -Match "^ {18}$" + } + + It "Should handle wide characters efficiently" { + $result = Format-RightText -Text "Wide" -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "^ {45}Wide $" + } + } + + Context "Mathematical calculations and logic" { + It "Should calculate padding correctly for various widths" { + $testCases = @( + @{ Text = "Hi"; Width = 5; ExpectedPadding = 2 } # "Hi " (3) needs 2 more + @{ Text = "Test"; Width = 8; ExpectedPadding = 3 } # "Test " (5) needs 3 more + @{ Text = "A"; Width = 10; ExpectedPadding = 8 } # "A " (2) needs 8 more + ) + + foreach ($case in $testCases) { + $result = Format-RightText -Text $case.Text -Width $case.Width + $paddingLength = $result.Length - ("$($case.Text) ").Length + $paddingLength | Should -Be $case.ExpectedPadding + } + } + + It "Should handle boundary condition where formatted text equals width" { + $result = Format-RightText -Text "Exact" -Width 6 + $result | Should -Be "Exact " + $result.Length | Should -Be 6 + # No additional padding should be added + } + + It "Should handle the greater-than-or-equal condition correctly" { + # Test the boundary where $Text.Length == $Width + $result = Format-RightText -Text "12345" -Width 6 # "12345 " = 6 chars + $result | Should -Be "12345 " + $result.Length | Should -Be 6 + + # Test where $Text.Length > $Width + $result2 = Format-RightText -Text "123456" -Width 6 # "123456 " = 7 chars > 6 + $result2 | Should -Be "123456 " + $result2.Length | Should -Be 7 + } + } + + Context "Parameter validation behavior" { + It "Should accept mandatory Text parameter" { + { Format-RightText -Text "Required" -Width 10 } | Should -Not -Throw + } + + It "Should accept mandatory Width parameter" { + { Format-RightText -Text "Test" -Width 5 } | Should -Not -Throw + } + + It "Should handle zero width gracefully" { + $result = Format-RightText -Text "Test" -Width 0 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle negative width gracefully" { + $result = Format-RightText -Text "Test" -Width -10 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle extremely large width values without error" { + # Test with large but reasonable width + $result = Format-RightText -Text "Big" -Width 200 + $result.Length | Should -Be 200 + $result | Should -Match "Big $" + } + } + + Context "String formatting consistency" { + It "Should maintain consistent formatting pattern" { + $testTexts = @("A", "AB", "ABC", "ABCD", "ABCDE") + $width = 10 + + foreach ($text in $testTexts) { + $result = Format-RightText -Text $text -Width $width + $result.Length | Should -Be $width + $result | Should -Match "$text $" + $result.Substring($result.Length - 1) | Should -Be " " + $result.Substring($result.Length - ($text.Length + 1), $text.Length) | Should -Be $text + } + } + + It "Should handle whitespace-only input" { + $result = Format-RightText -Text " " -Width 10 + $result | Should -Be " " # 7 leading spaces + " " + trailing space = 10 total + $result.Length | Should -Be 10 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-RightText.ps1 b/DevSetup/Private/Utils/Format-RightText.ps1 new file mode 100644 index 0000000..a7d64ce --- /dev/null +++ b/DevSetup/Private/Utils/Format-RightText.ps1 @@ -0,0 +1,12 @@ +Function Format-RightText { + param( + [Parameter(Mandatory=$true)] + [string]$Text, + [Parameter(Mandatory=$true)] + [int]$Width + ) + + $Text = "$Text " + if ($Text.Length -ge $Width) { return $Text } + return (' ' * ($Width - $Text.Length)) + $Text +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-DevSetupLogPath.Tests.ps1 b/DevSetup/Private/Utils/Get-DevSetupLogPath.Tests.ps1 new file mode 100644 index 0000000..bd9f734 --- /dev/null +++ b/DevSetup/Private/Utils/Get-DevSetupLogPath.Tests.ps1 @@ -0,0 +1,78 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Get-DevSetupLogPath.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Utils\Get-DevSetupPath.ps1") +} + +Describe "Get-DevSetupLogPath" { + Context "When the logs directory does not exist" { + It "should create the logs directory and return its path" { + $script:mockDevSetupPath = Join-Path $TestDrive "DevSetup" + $script:mockLogPath = Join-Path $script:mockDevSetupPath "logs" + + Mock Get-DevSetupPath { $script:mockDevSetupPath } + Mock Test-Path { $false } + Mock New-Item { } + + $result = Get-DevSetupLogPath + $result | Should -Be $script:mockLogPath + Assert-MockCalled Get-DevSetupPath -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $script:mockLogPath } + Assert-MockCalled New-Item -Exactly 1 -Scope It -ParameterFilter { $Path -eq $script:mockLogPath -and $ItemType -eq "Directory" } + } + } + + Context "When the logs directory already exists" { + It "should return the existing logs directory path" { + $script:mockDevSetupPath = Join-Path $TestDrive "DevSetup" + $script:mockLogPath = Join-Path $script:mockDevSetupPath "logs" + + Mock Get-DevSetupPath { $script:mockDevSetupPath } + Mock Test-Path { $true } + Mock New-Item { } + + $result = Get-DevSetupLogPath + $result | Should -Be $script:mockLogPath + Assert-MockCalled Get-DevSetupPath -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq $script:mockLogPath } + Assert-MockCalled New-Item -Exactly 0 -Scope It # Directory should not be created + } + } + + Context "Cross-platform compatibility" { + It "should work on Windows" { + $script:mockDevSetupPath = Join-Path $TestDrive "DevSetup" + $script:mockLogPath = Join-Path $script:mockDevSetupPath "logs" + + Mock Get-DevSetupPath { $script:mockDevSetupPath } + Mock Test-Path { $true } + Mock New-Item { } + + $result = Get-DevSetupLogPath + $result | Should -Be $script:mockLogPath + } + + It "should work on Linux" { + $script:mockDevSetupPath = Join-Path $TestDrive "DevSetup" + $script:mockLogPath = Join-Path $script:mockDevSetupPath "logs" + + Mock Get-DevSetupPath { $script:mockDevSetupPath } + Mock Test-Path { $true } + Mock New-Item { } + + $result = Get-DevSetupLogPath + $result | Should -Be $script:mockLogPath + } + + It "should work on macOS" { + $script:mockDevSetupPath = Join-Path $TestDrive "DevSetup" + $script:mockLogPath = Join-Path $script:mockDevSetupPath "logs" + + Mock Get-DevSetupPath { $script:mockDevSetupPath } + Mock Test-Path { $true } + Mock New-Item { } + + $result = Get-DevSetupLogPath + $result | Should -Be $script:mockLogPath + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-DevSetupManifest.Tests.ps1 b/DevSetup/Private/Utils/Get-DevSetupManifest.Tests.ps1 index 5d32d9c..c5553fc 100644 --- a/DevSetup/Private/Utils/Get-DevSetupManifest.Tests.ps1 +++ b/DevSetup/Private/Utils/Get-DevSetupManifest.Tests.ps1 @@ -1,22 +1,81 @@ BeforeAll { - . $PSScriptRoot\Get-DevSetupManifest.ps1 + . (Join-Path $PSScriptRoot "Get-DevSetupManifest.ps1") + . (Join-Path $PSScriptRoot "Test-OperatingSystem.ps1") + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } # Default to Windows + Mock Get-Module { [PSCustomObject]@{ ModuleBase = "$TestDrive\DevSetup" } } + Mock Test-Path { $true } + Mock Import-PowerShellDataFile { @{ ModuleVersion = "1.0.0" } } + Mock Write-Error { } } Describe "Get-DevSetupManifest" { - BeforeEach { - Mock Get-Module { - return @{ - ModuleBase = "$PSScriptRoot\..\..\..\DevSetup" - } + + Context "When DevSetup module is installed and manifest exists" { + It "Should return the manifest" { + $result = Get-DevSetupManifest + $result | Should -Not -Be $null + $result.ModuleVersion | Should -Be "1.0.0" + Assert-MockCalled Get-Module -Exactly 1 -Scope It -ParameterFilter { $Name -eq "DevSetup" } + Assert-MockCalled Test-Path -Exactly 1 -Scope It -ParameterFilter { $Path -eq (Join-Path "$TestDrive\DevSetup" "DevSetup.psd1") } + Assert-MockCalled Import-PowerShellDataFile -Exactly 1 -Scope It -ParameterFilter { $Path -eq (Join-Path "$TestDrive\DevSetup" "DevSetup.psd1") } } } - It "should return the manifest file and not null" { - $manifest = Get-DevSetupManifest - $manifest | Should -Not -BeNullOrEmpty + + Context "When DevSetup module is not installed" { + It "Should write error and return null" { + Mock Get-Module { $null } + $result = Get-DevSetupManifest + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "DevSetup module is not installed." } + } + } + + Context "When manifest file does not exist" { + It "Should write error and return null" { + Mock Test-Path { $false } + $result = Get-DevSetupManifest + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -match "DevSetup module manifest not found at" } + } + } + + Context "When Import-PowerShellDataFile fails" { + It "Should write error and return null" { + Mock Import-PowerShellDataFile { $null } + $result = Get-DevSetupManifest + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Failed to import DevSetup module manifest." } + } } - It "should contain the RootModule" { - $manifest = Get-DevSetupManifest - $manifest.RootModule | Should -Not -BeNullOrEmpty + Context "When exception occurs in try block" { + It "Should write error and return null" { + Mock Get-Module { throw "Unexpected error" } + $result = Get-DevSetupManifest + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to retrieve DevSetup manifest:" } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Get-DevSetupManifest + $result | Should -Not -Be $null + } + + It "Should work on Linux" { + $result = Get-DevSetupManifest + $result | Should -Not -Be $null + } + + It "Should work on macOS" { + $result = Get-DevSetupManifest + $result | Should -Not -Be $null + } } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-DevSetupPath.Tests.ps1 b/DevSetup/Private/Utils/Get-DevSetupPath.Tests.ps1 index 0f2bf30..07f1cd9 100644 --- a/DevSetup/Private/Utils/Get-DevSetupPath.Tests.ps1 +++ b/DevSetup/Private/Utils/Get-DevSetupPath.Tests.ps1 @@ -1,56 +1,118 @@ BeforeAll { - . $PSScriptRoot\Get-DevSetupPath.ps1 - . $PSScriptRoot\Get-EnvironmentVariable.ps1 - . $PSScriptRoot\Test-OperatingSystem.ps1 - Mock Test-OperatingSystem { $true } + . (Join-Path $PSScriptRoot "Get-DevSetupPath.ps1") + . (Join-Path $PSScriptRoot "Test-OperatingSystem.ps1") + . (Join-Path $PSScriptRoot "Get-EnvironmentVariable.ps1") + . (Join-Path $PSScriptRoot "Write-StatusMessage.ps1") + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } # Default to Windows + Mock Get-EnvironmentVariable { Param($Name) + if ($Name -eq "USERPROFILE") { + return "$TestDrive\Users\Joshua" + } elseif ($Name -eq "HOME") { + return "$TestDrive\home\joshua" + } + } + Mock Write-StatusMessage { } } Describe "Get-DevSetupPath" { - if ($PSVersionTable.PSVersion.Major -eq 5) { - Context "When running on Pwsh 5.1" { - BeforeEach { - Mock Get-EnvironmentVariable { return "$TestDrive\Users\Test User" } + + Context "When on Windows" { + It "Should return Windows path" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } } - It "should return the correct devsetup for the current user" { - $envPath = Get-DevSetupPath - $envPath | Should -Be "$TestDrive\Users\Test User\devsetup" + $result = Get-DevSetupPath + $result | Should -Be (Join-Path "$TestDrive\Users\Joshua" "devsetup") + Assert-MockCalled Get-EnvironmentVariable -Exactly 1 -Scope It -ParameterFilter { $Name -eq "USERPROFILE" } + } + } + + Context "When on Linux" { + It "Should return Linux path" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $true } + if ($MacOS) { return $false } } + $result = Get-DevSetupPath + $result | Should -Be (Join-Path "$TestDrive\home\joshua" "devsetup") + Assert-MockCalled Get-EnvironmentVariable -Exactly 1 -Scope It -ParameterFilter { $Name -eq "HOME" } } - } elseif ($PSVersionTable.PSVersion.Major -ge 6) { - Context "When running on Pwsh 6+" { - BeforeEach { - if ($IsWindows) { - Mock Get-EnvironmentVariable { return (Join-Path $TestDrive "Users" "Test User") } - } elseif( $IsLinux) { - Mock Get-EnvironmentVariable { return (Join-Path $TestDrive "home" "testuser") } - } elseif ($IsMacOS) { - Mock Get-EnvironmentVariable { return (Join-Path $TestDrive "Users" "TestUser") } - } - Mock Test-OperatingSystem { $true } + } + + Context "When on macOS" { + It "Should return macOS path" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $false } + if ($MacOS) { return $true } } + $result = Get-DevSetupPath + $result | Should -Be (Join-Path "$TestDrive\home\joshua" "devsetup") + Assert-MockCalled Get-EnvironmentVariable -Exactly 1 -Scope It -ParameterFilter { $Name -eq "HOME" } + } + } - if($IsLinux) { - It "should return the correct devsetup for the current user on Linux" { - $envPath = Get-DevSetupPath - $envPath | Should -Be (Join-Path $TestDrive "home" "testuser" "devsetup") - } + Context "When environment variable is not set" { + It "Should handle missing variable and return null" { + Mock Get-EnvironmentVariable { $null } + $result = Get-DevSetupPath + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { $Verbosity -eq "Error" } + } + } + + Context "When Join-Path fails" { + It "Should catch exception and return null" { + Mock Join-Path { throw "Join-Path failed" } + $result = Get-DevSetupPath + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { $Verbosity -eq "Error" } + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } } + $result = Get-DevSetupPath + $result | Should -Be (Join-Path "$TestDrive\Users\Joshua" "devsetup") + } - if($IsMacOS) { - It "should return the correct devsetup for the current user on MacOS" { - Mock Test-OperatingSystem { Param($Windows, $Linux, $MacOS) { return $MacOS } } - $envPath = Get-DevSetupPath - $envPath | Should -Be (Join-Path $TestDrive "Users" "TestUser" "devsetup") - } + It "Should work on Linux" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $true } + if ($MacOS) { return $false } } + $result = Get-DevSetupPath + $result | Should -Be (Join-Path "$TestDrive\home\joshua" "devsetup") + } - if($IsWindows) { - It "should return the correct devsetup for the current user on Windows" { - Mock Test-OperatingSystem { Param($Windows, $Linux, $MacOS) { return $Windows } } - $envPath = Get-DevSetupPath - $envPath | Should -Be (Join-Path $TestDrive "Users" "Test User" "devsetup") - } + It "Should work on macOS" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $false } + if ($MacOS) { return $true } } + $result = Get-DevSetupPath + $result | Should -Be (Join-Path "$TestDrive\home\joshua" "devsetup") } } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-DevSetupPath.ps1 b/DevSetup/Private/Utils/Get-DevSetupPath.ps1 index c16f15b..a47d766 100644 --- a/DevSetup/Private/Utils/Get-DevSetupPath.ps1 +++ b/DevSetup/Private/Utils/Get-DevSetupPath.ps1 @@ -1,4 +1,8 @@ Function Get-DevSetupPath { + [CmdletBinding()] + [OutputType([string])] + param () + # Get user's home directory if(Test-OperatingSystem -Windows) { $homeDirectory = Get-EnvironmentVariable USERPROFILE @@ -9,6 +13,12 @@ Function Get-DevSetupPath { } # Define .devsetup folder path - $devSetupPath = Join-Path -Path $homeDirectory -ChildPath "devsetup" - return $devSetupPath + try { + $devSetupPath = Join-Path -Path $homeDirectory -ChildPath "devsetup" + return $devSetupPath + } catch { + Write-StatusMessage "Failed to get DevSetup path: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-DevSetupVersion.Tests.ps1 b/DevSetup/Private/Utils/Get-DevSetupVersion.Tests.ps1 index df90555..2d502e2 100644 --- a/DevSetup/Private/Utils/Get-DevSetupVersion.Tests.ps1 +++ b/DevSetup/Private/Utils/Get-DevSetupVersion.Tests.ps1 @@ -1,36 +1,154 @@ BeforeAll { - . $PSScriptRoot\Get-DevSetupVersion.ps1 - . $PSScriptRoot\Get-DevSetupManifest.ps1 + Function Get-GitHubRelease { } + . (Join-Path $PSScriptRoot "Get-DevSetupVersion.ps1") + . (Join-Path $PSScriptRoot "Get-DevSetupManifest.ps1") + . (Join-Path $PSScriptRoot "Test-OperatingSystem.ps1") + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } # Default to Windows + Mock Get-DevSetupManifest { @{ ModuleVersion = "1.0.0"; PrivateData = @{ PSData = @{ ProjectUri = "https://github.com/pwshdevs/devsetup" } } } } + Mock Get-GitHubRelease { [PSCustomObject]@{ tag_name = "v1.0.1" } } + Mock Write-Error { } } Describe "Get-DevSetupVersion" { - BeforeEach { - Mock Get-DevSetupManifest { - return @{ - ModuleVersion = '1.0.0' - PrivateData = @{ - PSData = @{ - ProjectUri = 'https://github.com/your/repo' - } - } - } + + Context "When Local parameter is specified" { + It "Should return local version" { + $result = Get-DevSetupVersion -Local + $result | Should -BeOfType [Version] + $result.ToString() | Should -Be "1.0.0" + Assert-MockCalled Get-DevSetupManifest -Exactly 1 -Scope It + Assert-MockCalled Get-GitHubRelease -Exactly 0 -Scope It + } + } + + Context "When Remote parameter is specified" { + It "Should return remote version" { + $result = Get-DevSetupVersion -Remote + $result | Should -BeOfType [Version] + $result.ToString() | Should -Be "1.0.1" + Assert-MockCalled Get-DevSetupManifest -Exactly 1 -Scope It + Assert-MockCalled Get-GitHubRelease -Exactly 1 -Scope It + } + } + + Context "When no parameter is specified" { + It "Should default to Local" { + $result = Get-DevSetupVersion + $result | Should -BeOfType [Version] + $result.ToString() | Should -Be "1.0.0" + Assert-MockCalled Get-DevSetupManifest -Exactly 1 -Scope It + Assert-MockCalled Get-GitHubRelease -Exactly 0 -Scope It + } + } + + Context "When both Local and Remote are specified" { + It "Should write error and return null" { + $result = Get-DevSetupVersion -Local -Remote + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Local and Remote parameters are mutually exclusive. Please specify only one." } + } + } + + Context "When Get-DevSetupManifest fails" { + It "Should write error and return null" { + Mock Get-DevSetupManifest { $null } + $result = Get-DevSetupVersion -Local + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Failed to retrieve DevSetup module manifest." } + } + } + + Context "When ModuleVersion is missing in manifest" { + It "Should write error and return null" { + Mock Get-DevSetupManifest { @{ PrivateData = @{ PSData = @{ ProjectUri = "https://github.com/pwshdevs/devsetup" } } } } + $result = Get-DevSetupVersion -Local + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Version information not found in the DevSetup module manifest." } + } + } + + Context "When ModuleVersion is invalid" { + It "Should write error and return null" { + Mock Get-DevSetupManifest { @{ ModuleVersion = "invalid"; PrivateData = @{ PSData = @{ ProjectUri = "https://github.com/pwshdevs/devsetup" } } } } + $result = Get-DevSetupVersion -Local + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to parse version 'invalid' as a valid version object" } } + } - function Get-GitHubRelease {} + Context "When ProjectUri is missing for Remote" { + It "Should write error and return null" { + Mock Get-DevSetupManifest { @{ ModuleVersion = "1.0.0"; PrivateData = @{ PSData = @{ } } } } + $result = Get-DevSetupVersion -Remote + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "ProjectUri not found in the DevSetup module manifest." } + } + } + + Context "When Get-GitHubRelease fails" { + It "Should write error and return null" { + Mock Get-GitHubRelease { $null } + $result = Get-DevSetupVersion -Remote + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Failed to retrieve latest release information from GitHub." } + } + } - Mock Get-GitHubRelease { - return @{ - tag_name = '1.0.0' - } + Context "When tag_name is missing in release" { + It "Should write error and return null" { + Mock Get-GitHubRelease { [PSCustomObject]@{ } } + $result = Get-DevSetupVersion -Remote + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -eq "Failed to retrieve latest release information from GitHub." } } } - It "should return the correct version when looking locally" { - $version = Get-DevSetupVersion -Local - $version | Should -Be "1.0.0" + + Context "When tag_name is invalid for Remote" { + It "Should write error and return null" { + Mock Get-GitHubRelease { [PSCustomObject]@{ tag_name = "invalid" } } + $result = Get-DevSetupVersion -Remote + $result | Should -Be $null + Assert-MockCalled Write-Error -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to retrieve or parse remote version" } + } } - It "should return the correct version when looking remotely" { - $version = Get-DevSetupVersion -Remote - $version | Should -Be "1.0.0" + Context "When tag_name has 'v' prefix" { + It "Should remove prefix and parse correctly" { + Mock Get-GitHubRelease { [PSCustomObject]@{ tag_name = "v2.0.0" } } + $result = Get-DevSetupVersion -Remote + $result | Should -BeOfType [Version] + $result.ToString() | Should -Be "2.0.0" + } + } + + Context "When tag_name has no 'v' prefix" { + It "Should parse correctly" { + Mock Get-GitHubRelease { [PSCustomObject]@{ tag_name = "2.0.0" } } + $result = Get-DevSetupVersion -Remote + $result | Should -BeOfType [Version] + $result.ToString() | Should -Be "2.0.0" + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Get-DevSetupVersion -Local + $result | Should -BeOfType [Version] + } + + It "Should work on Linux" { + $result = Get-DevSetupVersion -Local + $result | Should -BeOfType [Version] + } + + It "Should work on macOS" { + $result = Get-DevSetupVersion -Local + $result | Should -BeOfType [Version] + } } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-EnvironmentVariable.Tests.ps1 b/DevSetup/Private/Utils/Get-EnvironmentVariable.Tests.ps1 index cd2c911..2de0544 100644 --- a/DevSetup/Private/Utils/Get-EnvironmentVariable.Tests.ps1 +++ b/DevSetup/Private/Utils/Get-EnvironmentVariable.Tests.ps1 @@ -1,5 +1,6 @@ BeforeAll { . $PSScriptRoot\Get-EnvironmentVariable.ps1 + . $PSScriptRoot\Test-OperatingSystem.ps1 } Describe "Get-EnvironmentVariable" { @@ -32,4 +33,72 @@ Describe "Get-EnvironmentVariable" { Remove-Item Env:\PIPE_VAR2 } } + + Context "When using different scopes" { + It "Should default to Process scope" { + $env:SCOPE_TEST = "ProcessValue" + $result = Get-EnvironmentVariable -Name "SCOPE_TEST" + $result | Should -Be "ProcessValue" + Remove-Item Env:\SCOPE_TEST + } + + It "Should handle Process scope explicitly" { + $env:SCOPE_TEST = "ProcessValue" + $result = Get-EnvironmentVariable -Name "SCOPE_TEST" -Scope "Process" + $result | Should -Be "ProcessValue" + Remove-Item Env:\SCOPE_TEST + } + + It "Should handle User scope on Windows" { + # Test should not throw an exception and should return something or null + { Get-EnvironmentVariable -Name "PATH" -Scope "User" } | Should -Not -Throw + # Get the actual result and verify type if not null + $result = Get-EnvironmentVariable -Name "PATH" -Scope "User" + if ($result -ne $null) { + $result | Should -BeOfType [string] + } + } + + It "Should handle Machine scope on Windows" { + # Test should not throw an exception and should return something or null + { Get-EnvironmentVariable -Name "PATH" -Scope "Machine" } | Should -Not -Throw + # Get the actual result and verify type if not null + $result = Get-EnvironmentVariable -Name "PATH" -Scope "Machine" + if ($result -ne $null) { + $result | Should -BeOfType [string] + } + } + + It "Should return null for User scope on non-Windows when IsWindows is false" { + # Mock Test-OperatingSystem to return false for Windows + Mock Test-OperatingSystem { return $false } -ParameterFilter { $Windows -eq $true } + + $result = Get-EnvironmentVariable -Name "PATH" -Scope "User" + $result | Should -Be $null + } + + It "Should return null for Machine scope on non-Windows when IsWindows is false" { + # Mock Test-OperatingSystem to return false for Windows + Mock Test-OperatingSystem { return $false } -ParameterFilter { $Windows -eq $true } + + $result = Get-EnvironmentVariable -Name "PATH" -Scope "Machine" + $result | Should -Be $null + } + + It "Should call Test-OperatingSystem when using User scope" { + Mock Test-OperatingSystem { return $true } -ParameterFilter { $Windows -eq $true } + + Get-EnvironmentVariable -Name "PATH" -Scope "User" | Out-Null + + Assert-MockCalled Test-OperatingSystem -Exactly 1 -Scope It -ParameterFilter { $Windows -eq $true } + } + + It "Should call Test-OperatingSystem when using Machine scope" { + Mock Test-OperatingSystem { return $true } -ParameterFilter { $Windows -eq $true } + + Get-EnvironmentVariable -Name "PATH" -Scope "Machine" | Out-Null + + Assert-MockCalled Test-OperatingSystem -Exactly 1 -Scope It -ParameterFilter { $Windows -eq $true } + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-EnvironmentVariable.ps1 b/DevSetup/Private/Utils/Get-EnvironmentVariable.ps1 index b0f730c..0723fac 100644 --- a/DevSetup/Private/Utils/Get-EnvironmentVariable.ps1 +++ b/DevSetup/Private/Utils/Get-EnvironmentVariable.ps1 @@ -2,9 +2,42 @@ Function Get-EnvironmentVariable { [cmdletbinding()] param ( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] - [string]$Name + [string]$Name, + + [Parameter()] + [ValidateSet("Process", "User", "Machine")] + [string]$Scope = "Process" ) process { - Write-Output ([System.Environment]::GetEnvironmentVariable($Name)) + try { + # Handle different scopes for environment variables + switch ($Scope) { + "Process" { + Write-Output ([System.Environment]::GetEnvironmentVariable($Name)) + } + "User" { + # On Windows, get User-scoped environment variables + # On non-Windows platforms, this will return $null + if (Test-OperatingSystem -Windows) { + Write-Output ([System.Environment]::GetEnvironmentVariable($Name, "User")) + } else { + Write-Output $null + } + } + "Machine" { + # On Windows, get Machine-scoped environment variables + # On non-Windows platforms, this will return $null + if (Test-OperatingSystem -Windows) { + Write-Output ([System.Environment]::GetEnvironmentVariable($Name, "Machine")) + } else { + Write-Output $null + } + } + } + } + catch { + # If there's an error accessing environment variables, return $null + Write-Output $null + } } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-HostArchitecture.Tests.ps1 b/DevSetup/Private/Utils/Get-HostArchitecture.Tests.ps1 new file mode 100644 index 0000000..60bbaf0 --- /dev/null +++ b/DevSetup/Private/Utils/Get-HostArchitecture.Tests.ps1 @@ -0,0 +1,55 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Get-HostArchitecture.ps1") + Mock Invoke-Command { $true } # Default to x64 +} + +Describe "Get-HostArchitecture" { + + Context "When system is 64-bit" { + It "Should return x64" { + Mock Invoke-Command { $true } + $result = Get-HostArchitecture + $result | Should -Be "x64" + } + } + + Context "When system is 32-bit" { + It "Should return x86" { + Mock Invoke-Command { $false } + $result = Get-HostArchitecture + $result | Should -Be "x86" + } + } + + Context "When Invoke-Command fails" { + It "Should return x86 as default" { + Mock Invoke-Command { throw "Invoke-Command failed" } + $result = Get-HostArchitecture + $result | Should -Be "x86" + } + } + + if ($PSVersionTable.PSVersion.Major -ge 6) { + Context "Cross-platform compatibility" { + if ($IsWindows) { + It "Should work on Windows" { + Mock Invoke-Command { $true } + $result = Get-HostArchitecture + $result | Should -Be "x64" + } + } elseif ($IsLinux) { + It "Should work on Linux" { + Mock Invoke-Command { $true } + $result = Get-HostArchitecture + $result | Should -Be "x64" + } + } elseif ($IsMacOS) { + It "Should work on macOS" { + Mock Invoke-Command { $true } + $result = Get-HostArchitecture + $result | Should -Be "x64" + } + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-HostArchitecture.ps1 b/DevSetup/Private/Utils/Get-HostArchitecture.ps1 new file mode 100644 index 0000000..1e0c789 --- /dev/null +++ b/DevSetup/Private/Utils/Get-HostArchitecture.ps1 @@ -0,0 +1,13 @@ +Function Get-HostArchitecture { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] + [cmdletbinding()] + [OutputType([string])] + Param() + try { + $systemArch = Invoke-Command -Script { [System.Environment]::Is64BitOperatingSystem } + $architecture = if ($systemArch) { "x64" } else { "x86" } + return $architecture + } catch { + return "x86" + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-HostOperatingSystem.Tests.ps1 b/DevSetup/Private/Utils/Get-HostOperatingSystem.Tests.ps1 new file mode 100644 index 0000000..954868f --- /dev/null +++ b/DevSetup/Private/Utils/Get-HostOperatingSystem.Tests.ps1 @@ -0,0 +1,144 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Get-HostOperatingSystem.ps1") + . (Join-Path $PSScriptRoot "Write-StatusMessage.ps1") + Mock Write-StatusMessage { } +} + +Describe "Get-HostOperatingSystem" { + + Context "When on Windows" { + It "Should return Windows" { + Mock Invoke-Command { + return "Win32NT" + } + $result = Get-HostOperatingSystem + $result | Should -Be "Windows" + } + } + + Context "When Invoke-Command throws exception" { + It "Should return Windows" { + Mock Invoke-Command { + throw "Test exception" + } + $result = Get-HostOperatingSystem + $result | Should -Be "Windows" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to determine operating system platform" -and $Verbosity -eq "Error" } + } + } + + Context "When on Linux and Invoke-Command throws exception" { + It "Should return Windows" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "Unix" + } + throw "Test exception" + } + $result = Get-HostOperatingSystem + $result | Should -Be "Linux" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to determine operating system platform using uname" -and $Verbosity -eq "Error" } + } + } + + Context "When on Linux" { + It "Should return Linux" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "Unix" + } + return "Linux" + } + $result = Get-HostOperatingSystem + $result | Should -Be "Linux" + } + } + + Context "When on macOS" { + It "Should return macOS" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "Unix" + } + return "Darwin" + } + $result = Get-HostOperatingSystem + $result | Should -Be "macOS" + } + } + + Context "When platform is unknown" { + It "Should return the platform string" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "UnknownPlatform" + } + } + $result = Get-HostOperatingSystem + $result | Should -Be "UnknownPlatform" + } + } + + Context "When uname fails" { + It "Should return Linux as default for Unix" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "Unix" + } + throw "uname failed" + } + $result = Get-HostOperatingSystem + $result | Should -Be "Linux" + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "Win32NT" + } + } + $result = Get-HostOperatingSystem + $result | Should -Be "Windows" + } + + It "Should work on Linux" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "Unix" + } + return "Linux" + } + $result = Get-HostOperatingSystem + $result | Should -Be "Linux" + } + + It "Should work on macOS" { + $script:callCount = 0 + Mock Invoke-Command { + if ($script:callCount -eq 0) { + $script:callCount++ + return "Unix" + } + return "Darwin" + } + $result = Get-HostOperatingSystem + $result | Should -Be "macOS" + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-HostOperatingSystem.ps1 b/DevSetup/Private/Utils/Get-HostOperatingSystem.ps1 new file mode 100644 index 0000000..3f8b84a --- /dev/null +++ b/DevSetup/Private/Utils/Get-HostOperatingSystem.ps1 @@ -0,0 +1,37 @@ +Function Get-HostOperatingSystem { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] + [cmdletbinding()] + [OutputType([string])] + Param() + try { + # Use Invoke-Command to allow mocking in tests + $platform = Invoke-Command -Script { [System.Environment]::OSVersion.Platform.ToString() } + } catch { + Write-StatusMessage "Failed to determine operating system platform: $_" -Verbosity Error + return "Windows" # Default to Windows if detection fails + } + $DecodedPlatform = switch ($platform) { + "Win32NT" { + "Windows" + } + + "Unix" { + $uname = $null + try { + $uname = Invoke-Command -Script { & uname -s } 2>$null + } catch { + Write-StatusMessage "Failed to determine operating system platform using uname: $_" -Verbosity Error + } + if ($uname -eq "Darwin") { + "macOS" + } else { + "Linux" + } + } + + default { + $platform + } + } + return $DecodedPlatform +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-HostOperatingSystemVersion.Tests.ps1 b/DevSetup/Private/Utils/Get-HostOperatingSystemVersion.Tests.ps1 new file mode 100644 index 0000000..2793c75 --- /dev/null +++ b/DevSetup/Private/Utils/Get-HostOperatingSystemVersion.Tests.ps1 @@ -0,0 +1,273 @@ +BeforeAll { + Function Get-CimInstance { } + . (Join-Path $PSScriptRoot "Get-HostOperatingSystemVersion.ps1") + . (Join-Path $PSScriptRoot "Get-HostOperatingSystem.ps1") + . (Join-Path $PSScriptRoot "Write-StatusMessage.ps1") + Mock Get-HostOperatingSystem { "Windows" } # Default to Windows + Mock Get-CimInstance { [PSCustomObject]@{ Caption = "Microsoft Windows 10 Pro" } } + Mock Test-Path { $true } + Mock Get-Content { 'PRETTY_NAME="Ubuntu 20.04.3 LTS"' } + Mock Write-StatusMessage { } +} + +Describe "Get-HostOperatingSystemVersion" { + Context "When Invoke-Command throws exception" { + It "Should return Unknown and log error" { + Mock Invoke-Command { throw "Test exception" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Unknown" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get OS version string" -and $Verbosity -eq "Error" } + } + } + + Context "When Invoke-Command returns empty string" { + It "Should run default of windows logic and return Windows 10 Pro" { + Mock Invoke-Command { return "" } + Mock Get-HostOperatingSystem { "Windows" } # Default to Windows + Mock Get-CimInstance { [PSCustomObject]@{ Caption = "Microsoft Windows 10 Pro" } } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Windows 10 Pro" + } + } + + Context "When Get-HostOperatingSystem fails" { + It "Should return Unknown and log error" { + Mock Invoke-Command { return "Unknown" } + Mock Get-HostOperatingSystem { throw "Test exception" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Unknown" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get friendly OS platform" -and $Verbosity -eq "Error" } + } + } + + Context "When on Windows and Get-CimInstance succeeds" { + It "Should return friendly Windows version" { + Mock Invoke-Command { + return "Win32NT" + } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-CimInstance { [PSCustomObject]@{ Caption = "Microsoft Windows 10 Pro" } } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Windows 10 Pro" + } + } + + Context "When on Windows and Get-CimInstance fails" { + It "Should return Microsoft Windows NT 10.0.19041.0" { + Mock Invoke-Command { + return "Microsoft Windows NT 10.0.19041.0" + } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-CimInstance { throw "Get-CimInstance failed" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Microsoft Windows NT 10.0.19041.0" + } + } + + Context "When on Windows and Get-CimInstance returns null" { + It "Should return Microsoft Windows NT 10.0.19041.0" { + Mock Invoke-Command { + return "Microsoft Windows NT 10.0.19041.0" + } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-CimInstance { $null } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Microsoft Windows NT 10.0.19041.0" + } + } + + Context "When on macOS and sw_vers succeeds" { + It "Should return friendly macOS version" { + $script:callCount = 0 + Mock Invoke-Command { + if($script:callCount -eq 0) { + $script:callCount++ + return "Unix" + } else { + return "11.6" + } + } + Mock Get-HostOperatingSystem { "macOS" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "macOS 11.6" + } + } + + Context "When on macOS and sw_vers fails" { + It "Should return OSVersion.VersionString" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unix" + } + 1 { + $script:callCount++ + throw "sw_vers failed" + } + } + } + Mock Get-HostOperatingSystem { "macOS" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Unix" + } + } + + Context "When on macOS and sw_vers returns empty" { + It "Should return Unix" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unix" + } + 1 { + $script:callCount++ + return "" + } + } + } + Mock Get-HostOperatingSystem { "macOS" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Unix" + } + } + + Context "When on Linux and /etc/os-release exists" { + It "Should return friendly Linux version" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unix" + } + 1 { + $script:callCount++ + return "5.4.0" + } + } + } + Mock Get-HostOperatingSystem { "Linux" } + Mock Test-Path { $true } + Mock Get-Content { 'PRETTY_NAME="Ubuntu 20.04.3 LTS"' } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Ubuntu 20.04.3 LTS" + } + } + + Context "When on Linux and /etc/os-release does not exist" { + It "Should return Unix 5.4.0" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unix 5.4.0" + } + } + } + Mock Get-HostOperatingSystem { "Linux" } + Mock Test-Path { $false } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Unix 5.4.0" + } + } + + Context "When on Linux and test-path throws exception" { + It "Should return Unix 5.4.0" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unix 5.4.0" + } + } + } + Mock Get-HostOperatingSystem { "Linux" } + Mock Test-Path { throw "Test-Path failed" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Unix 5.4.0" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to get Linux OS information" -and $Verbosity -eq "Error" } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { $Verbosity -eq "Error" } + } + } + + Context "When platform is unknown" { + It "Should return Unknown OS" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unknown OS" + } + } + } + Mock Get-HostOperatingSystem { "Unknown" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Unknown OS" + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Win32NT" + } + } + } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-CimInstance { [PSCustomObject]@{ Caption = "Microsoft Windows 10 Pro" } } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Windows 10 Pro" + } + + It "Should work on Linux" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unix" + } + 1 { + $script:callCount++ + return "5.4.0" + } + } + } + Mock Get-HostOperatingSystem { "Linux" } + Mock Test-Path { $true } + Mock Get-Content { 'PRETTY_NAME="Ubuntu 20.04.3 LTS"' } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "Ubuntu 20.04.3 LTS" + } + + It "Should work on macOS" { + $script:callCount = 0 + Mock Invoke-Command { + switch($script:callCount) { + 0 { + $script:callCount++ + return "Unix" + } + 1 { + $script:callCount++ + return "11.6" + } + } + } + Mock Get-HostOperatingSystem { "macOS" } + $result = Get-HostOperatingSystemVersion + $result | Should -Be "macOS 11.6" + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Get-HostOperatingSystemVersion.ps1 b/DevSetup/Private/Utils/Get-HostOperatingSystemVersion.ps1 new file mode 100644 index 0000000..7069158 --- /dev/null +++ b/DevSetup/Private/Utils/Get-HostOperatingSystemVersion.ps1 @@ -0,0 +1,81 @@ +Function Get-HostOperatingSystemVersion { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] + [cmdletbinding()] + [OutputType([string])] + Param() + + $unfriendlyOsVersion = "Unknown" + try { + $unfriendlyOsVersion = Invoke-Command -Script { [System.Environment]::OSVersion.VersionString } + if ([string]::IsNullOrEmpty($unfriendlyOsVersion)) { + $unfriendlyOsVersion = "Unknown" + } + } catch { + Write-StatusMessage "Failed to get OS version string: $_" -Verbosity Error + return $unfriendlyOsVersion # Default to Windows if detection fails + } + + try { + $friendlyPlatform = (Get-HostOperatingSystem) + } catch { + Write-StatusMessage "Failed to get friendly OS platform: $_" -Verbosity Error + return $unfriendlyOsVersion + } + + $friendlyOsVersion = switch($friendlyPlatform) { + "Windows" { + try { + $osInfo = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue + if (-not ([string]::IsNullOrEmpty($osInfo))) { + $osInfo.Caption -replace "Microsoft ", "" + } else { + $unfriendlyOsVersion + } + } + catch { + Write-StatusMessage "Failed to get Windows OS information: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + $unfriendlyOsVersion + } + } + "macOS" { + try { + $macVersion = Invoke-Command -Script { & sw_vers -productVersion 2>$null } + if (-not ([string]::IsNullOrEmpty($macVersion))) { + "macOS $macVersion" + } else { + $unfriendlyOsVersion + } + } + catch { + Write-StatusMessage "Failed to get macOS information: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + $unfriendlyOsVersion + } + } + "Linux" { + try { + $linuxVersion = $null + if (Test-Path "/etc/os-release") { + $osRelease = Get-Content "/etc/os-release" | Where-Object { $_ -like "PRETTY_NAME=*" } + if ($osRelease) { + $linuxVersion = ($osRelease -split '=')[1] -replace '"', '' + } + } + if (-not ([string]::IsNullOrEmpty($linuxVersion))) { + $linuxVersion + } else { + $unfriendlyOsVersion + } + } + catch { + Write-StatusMessage "Failed to get Linux OS information: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + $unfriendlyOsVersion + } + } + default { $unfriendlyOsVersion } + } + + return $friendlyOsVersion +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Invoke-ExternalCommand.Tests.ps1 b/DevSetup/Private/Utils/Invoke-ExternalCommand.Tests.ps1 new file mode 100644 index 0000000..7a7ea38 --- /dev/null +++ b/DevSetup/Private/Utils/Invoke-ExternalCommand.Tests.ps1 @@ -0,0 +1,275 @@ +BeforeAll { + . "$PSScriptRoot\Invoke-ExternalCommand.ps1" +} + +Describe "Invoke-ExternalCommand" { + Context "Basic functionality" { + It "should execute a simple command without arguments" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # Using PowerShell's echo equivalent + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Write-Output 'test'") + $result | Should -Contain "test" + } else { + # Using echo which should exist on Linux + $result = Invoke-ExternalCommand -Command "echo" -Arguments @("test") + $result | Should -Contain "test" + } + } + + It "should execute a command with single argument" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Write-Output 'hello world'") + $result | Should -Contain "hello world" + } else { + $result = Invoke-ExternalCommand -Command "echo" -Arguments @("hello world") + $result | Should -Contain "hello world" + } + } + + It "should execute a command with multiple arguments" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Write-Output 'arg1'; Write-Output 'arg2'") + $result | Should -Contain "arg1" + $result | Should -Contain "arg2" + } else { + # Use printf to output multiple lines + $result = Invoke-ExternalCommand -Command "printf" -Arguments @("%s\n%s\n", "arg1", "arg2") + $result | Should -Contain "arg1" + $result | Should -Contain "arg2" + } + } + + It "should work without specifying Arguments parameter" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # On Windows, use a command that works safely + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Get-Date -Format yyyy") + $result | Should -Match "\d{4}" + } else { + # On Linux, use date command + $result = Invoke-ExternalCommand -Command "date" -Arguments @("+%Y") + $result | Should -Match "\d{4}" + } + } + } + + Context "Parameter validation" { + It "should handle null or empty Command parameter" { + { Invoke-ExternalCommand -Command "" } | Should -Throw + { Invoke-ExternalCommand -Command $null } | Should -Throw + } + + It "should accept empty Arguments array" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + { Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Get-Date -Format yyyy") } | Should -Not -Throw + } else { + { Invoke-ExternalCommand -Command "date" -Arguments @("+%Y") } | Should -Not -Throw + } + } + + It "should accept null Arguments" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # PowerShell with null args would hang, so provide safe command + { Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "exit") } | Should -Not -Throw + } else { + # Date without arguments should work fine + { Invoke-ExternalCommand -Command "date" -Arguments $null } | Should -Not -Throw + } + } + } + + Context "Output capture" { + It "should capture standard output" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Write-Output 'stdout test'") + $result | Should -Contain "stdout test" + } else { + $result = Invoke-ExternalCommand -Command "echo" -Arguments @("stdout test") + $result | Should -Contain "stdout test" + } + } + + It "should capture error output (2>&1 redirection)" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # Use a command that will generate an error + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Write-Error 'error test' -ErrorAction Continue") + # The error should be captured as part of the output due to 2>&1 redirection + $result | Should -Not -BeNullOrEmpty + } else { + # Use sh to echo to stderr - avoiding direct bash usage to prevent PATH issues + $result = Invoke-ExternalCommand -Command "sh" -Arguments @("-c", "echo 'error test' >&2") + $result | Should -Not -BeNullOrEmpty + } + } + + It "should return array for multi-line output" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Write-Output 'line1'; Write-Output 'line2'") + $result.Count | Should -BeGreaterThan 1 + $result | Should -Contain "line1" + $result | Should -Contain "line2" + } else { + # Use printf for multi-line output + $result = Invoke-ExternalCommand -Command "printf" -Arguments @("%s\n%s\n", "line1", "line2") + $result.Count | Should -BeGreaterThan 1 + } + } + } + + Context "Homebrew-like usage patterns" { + It "should handle homebrew list --versions pattern" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # Simulate the homebrew list --versions command + $command = "powershell" + $arguments = @("-Command", "`$output = @('git 2.30.1', 'node 14.17.0', 'python 3.9.0'); `$output") + } else { + # Use printf to simulate homebrew output without using bash + $command = "printf" + $arguments = @("%s\n%s\n%s\n", "git 2.30.1", "node 14.17.0", "python 3.9.0") + } + + $result = Invoke-ExternalCommand -Command $command -Arguments $arguments + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -BeGreaterThan 1 + } + + It "should handle homebrew list --installed-on-request pattern" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # Simulate the homebrew installed packages command + $command = "powershell" + $arguments = @("-Command", "`$output = @('git', 'node'); `$output") + } else { + # Use printf to simulate homebrew output + $command = "printf" + $arguments = @("%s\n%s\n", "git", "node") + } + + $result = Invoke-ExternalCommand -Command $command -Arguments $arguments + $result | Should -Not -BeNullOrEmpty + } + + It "should work with shell command pattern commonly used in homebrew providers" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # Test the pattern used in homebrew providers but with PowerShell + $command = "powershell" + $arguments = @("-Command", "Get-Date | Select-Object -ExpandProperty Year") + $result = Invoke-ExternalCommand -Command $command -Arguments $arguments + $result | Should -Match "\d{4}" + } else { + # Use date command pattern on Linux + $command = "date" + $arguments = @("+%Y") + $result = Invoke-ExternalCommand -Command $command -Arguments $arguments + $result | Should -Match "\d{4}" + } + } + } + + Context "Error handling" { + It "should propagate command not found errors" { + { Invoke-ExternalCommand -Command "nonexistentcommand123456" -Arguments @("test") } | Should -Throw + } + + It "should handle commands that return non-zero exit codes" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + # PowerShell command that exits with non-zero code + { Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "exit 1") } | Should -Not -Throw + } else { + # Use sh command that exits with non-zero code + { Invoke-ExternalCommand -Command "sh" -Arguments @("-c", "exit 1") } | Should -Not -Throw + } + # The function should complete without throwing + } + } + + Context "Command line construction" { + It "should build correct command line with arguments" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $command = "powershell" + $arguments = @("-Command", "Write-Output 'test'") + } else { + $command = "echo" + $arguments = @("test") + } + + { Invoke-ExternalCommand -Command $command -Arguments $arguments } | Should -Not -Throw + } + + It "should handle arguments with spaces" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $command = "powershell" + $arguments = @("-Command", "Write-Output 'argument with spaces'") + } else { + $command = "echo" + $arguments = @("argument with spaces") + } + + { Invoke-ExternalCommand -Command $command -Arguments $arguments } | Should -Not -Throw + } + + It "should handle special characters in arguments" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $command = "powershell" + $arguments = @("-Command", "Write-Output 'test with special chars!'") + } else { + $command = "echo" + $arguments = @("test with special chars!") + } + + { Invoke-ExternalCommand -Command $command -Arguments $arguments } | Should -Not -Throw + } + } + + Context "Return value behavior" { + It "should return output object that can be piped" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "1..3 | ForEach-Object { `$_ }") + } else { + # Use seq command which should be available on most Linux systems + $result = Invoke-ExternalCommand -Command "seq" -Arguments @("1", "3") + } + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -BeGreaterThan 1 + } + + It "should preserve output order" { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Write-Output 'first'; Write-Output 'second'; Write-Output 'third'") + $result[0] | Should -Be "first" + $result[1] | Should -Be "second" + $result[2] | Should -Be "third" + } else { + # Use printf to ensure proper ordering + $result = Invoke-ExternalCommand -Command "printf" -Arguments @("%s\n%s\n%s\n", "first", "second", "third") + $result[0] | Should -Be "first" + } + } + } + + Context "Cross-platform compatibility" { + It "should work on Windows with PowerShell commands" -Skip:(-not ($IsWindows -or $env:OS -eq 'Windows_NT')) { + $result = Invoke-ExternalCommand -Command "powershell" -Arguments @("-Command", "Get-Date -Format yyyy") + $result | Should -Match "\d{4}" + } + + It "should work on Unix-like systems with common commands" -Skip:($IsWindows -or $env:OS -eq 'Windows_NT') { + # Use date command which should be universally available + $result = Invoke-ExternalCommand -Command "date" -Arguments @("+%Y") + $result | Should -Match "\d{4}" + } + + It "should work with common cross-platform commands" { + # Use commands that exist on both platforms + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $command = "powershell" + $arguments = @("-Command", "Get-Location | Select-Object -ExpandProperty Path") + } else { + $command = "pwd" + $arguments = @() + } + + $result = Invoke-ExternalCommand -Command $command -Arguments $arguments + $result | Should -Not -BeNullOrEmpty + } + } +} diff --git a/DevSetup/Private/Utils/Invoke-ExternalCommand.ps1 b/DevSetup/Private/Utils/Invoke-ExternalCommand.ps1 index 12f5155..e03913e 100644 --- a/DevSetup/Private/Utils/Invoke-ExternalCommand.ps1 +++ b/DevSetup/Private/Utils/Invoke-ExternalCommand.ps1 @@ -1,4 +1,5 @@ Function Invoke-ExternalCommand { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] [CmdletBinding()] Param( [Parameter(Mandatory)] @@ -14,6 +15,10 @@ Function Invoke-ExternalCommand { } # Invoke the command and capture output - $output = & $Command @Arguments 2>&1 + if ($Arguments -and $Arguments.Count -gt 0) { + $output = & $Command @Arguments 2>&1 + } else { + $output = & $Command 2>&1 + } return $output } \ No newline at end of file diff --git a/DevSetup/Private/Utils/New-DevSetupEnvFile.Tests.ps1 b/DevSetup/Private/Utils/New-DevSetupEnvFile.Tests.ps1 new file mode 100644 index 0000000..d545192 --- /dev/null +++ b/DevSetup/Private/Utils/New-DevSetupEnvFile.Tests.ps1 @@ -0,0 +1,263 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "New-DevSetupEnvFile.ps1") +} + +Describe "New-DevSetupEnvFile" { + Context "Basic functionality" { + It "should return a PSCustomObject" { + $result = New-DevSetupEnvFile + $result | Should -BeOfType [PSCustomObject] + } + + It "should contain the devsetup root key" { + $result = New-DevSetupEnvFile + $result.PSObject.Properties.Name | Should -Contain "devsetup" + } + + It "should have devsetup as PSCustomObject" { + $result = New-DevSetupEnvFile + $result.devsetup | Should -BeOfType [PSCustomObject] + } + } + + Context "Required top-level sections" { + It "should contain dependencies section" { + $result = New-DevSetupEnvFile + $result.devsetup.PSObject.Properties.Name | Should -Contain "dependencies" + } + + It "should contain commands section" { + $result = New-DevSetupEnvFile + $result.devsetup.PSObject.Properties.Name | Should -Contain "commands" + } + + It "should contain configuration section" { + $result = New-DevSetupEnvFile + $result.devsetup.PSObject.Properties.Name | Should -Contain "configuration" + } + } + + Context "Dependencies structure" { + It "should have dependencies as PSCustomObject" { + $result = New-DevSetupEnvFile + $result.devsetup.dependencies | Should -BeOfType [PSCustomObject] + } + + It "should contain all standard package managers" { + $result = New-DevSetupEnvFile + $dependencies = $result.devsetup.dependencies + $dependencies.PSObject.Properties.Name | Should -Contain "chocolatey" + $dependencies.PSObject.Properties.Name | Should -Contain "powershell" + $dependencies.PSObject.Properties.Name | Should -Contain "scoop" + $dependencies.PSObject.Properties.Name | Should -Contain "homebrew" + } + + It "should have chocolatey with empty packages array" { + $result = New-DevSetupEnvFile + $result.devsetup.dependencies.chocolatey | Should -Not -BeNullOrEmpty + $result.devsetup.dependencies.chocolatey.packages.Count | Should -Be 0 + } + + It "should have powershell with modules array and scope" { + $result = New-DevSetupEnvFile + $powershell = $result.devsetup.dependencies.powershell + $powershell.modules.Count | Should -Be 0 + $powershell.scope | Should -Be "CurrentUser" + } + + It "should have scoop with empty packages and buckets arrays" { + $result = New-DevSetupEnvFile + $scoop = $result.devsetup.dependencies.scoop + $scoop.packages.Count | Should -Be 0 + $scoop.buckets.Count | Should -Be 0 + } + + It "should have homebrew with empty packages array" { + $result = New-DevSetupEnvFile + $homebrew = $result.devsetup.dependencies.homebrew + $homebrew.packages.Count | Should -Be 0 + } + } + + Context "Commands structure" { + It "should have commands as empty array" { + $result = New-DevSetupEnvFile + $result.devsetup.commands.Count | Should -Be 0 + } + } + + Context "Configuration structure" { + It "should have configuration as OrderedDictionary" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + } + + It "should contain all required configuration fields" { + $result = New-DevSetupEnvFile + $config = $result.devsetup.configuration + $config.Keys | Should -Contain "description" + $config.Keys | Should -Contain "version" + $config.Keys | Should -Contain "createdDate" + $config.Keys | Should -Contain "lastModified" + $config.Keys | Should -Contain "createdBy" + $config.Keys | Should -Contain "os" + $config.Keys | Should -Contain "powershell" + } + + It "should have default description" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.description | Should -Be "Auto-generated development environment configuration" + } + + It "should have default version" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.version | Should -Be "1.0.0" + } + + It "should have createdDate as string with current timestamp" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.createdDate | Should -BeOfType [System.String] + $result.devsetup.configuration.createdDate | Should -Match '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}' + } + + It "should have lastModified as string with current timestamp" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.lastModified | Should -BeOfType [System.String] + $result.devsetup.configuration.lastModified | Should -Match '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}' + } + + It "should have createdBy as null initially" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.createdBy | Should -BeNullOrEmpty + } + + Context "OS information" { + It "should have os as PSCustomObject" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.os | Should -BeOfType [PSCustomObject] + } + + It "should have all OS fields as null initially" { + $result = New-DevSetupEnvFile + $os = $result.devsetup.configuration.os + $os.name | Should -BeNullOrEmpty + $os.version | Should -BeNullOrEmpty + $os.architecture | Should -BeNullOrEmpty + } + + It "should contain required OS fields" { + $result = New-DevSetupEnvFile + $os = $result.devsetup.configuration.os + $os.PSObject.Properties.Name | Should -Contain "name" + $os.PSObject.Properties.Name | Should -Contain "version" + $os.PSObject.Properties.Name | Should -Contain "architecture" + } + } + + Context "PowerShell information" { + It "should have powershell as PSCustomObject" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.powershell | Should -BeOfType [PSCustomObject] + } + + It "should have current PowerShell version" { + $result = New-DevSetupEnvFile + $ps = $result.devsetup.configuration.powershell + $ps.version | Should -Be $PSVersionTable.PSVersion.ToString() + } + + It "should have current PowerShell edition" { + $result = New-DevSetupEnvFile + $ps = $result.devsetup.configuration.powershell + $ps.edition | Should -Be $PSVersionTable.PSEdition + } + + It "should contain required PowerShell fields" { + $result = New-DevSetupEnvFile + $ps = $result.devsetup.configuration.powershell + $ps.PSObject.Properties.Name | Should -Contain "version" + $ps.PSObject.Properties.Name | Should -Contain "edition" + } + } + } + + Context "Validation compatibility" { + It "should pass Assert-DevSetupEnvValid validation" { + # This test ensures the canonical structure is always valid + . (Join-Path $PSScriptRoot "Assert-DevSetupEnvValid.ps1") + $result = New-DevSetupEnvFile + { Assert-DevSetupEnvValid $result } | Should -Not -Throw + } + } + + Context "Timestamp consistency" { + It "should have createdDate and lastModified within reasonable time range" { + $beforeCall = Get-Date + Start-Sleep -Milliseconds 10 # Small delay to ensure timestamp precision + $result = New-DevSetupEnvFile + Start-Sleep -Milliseconds 10 # Small delay to ensure timestamp precision + $afterCall = Get-Date + + $createdDate = [DateTime]::ParseExact($result.devsetup.configuration.createdDate, "yyyy-MM-dd HH:mm:ss", $null) + $lastModified = [DateTime]::ParseExact($result.devsetup.configuration.lastModified, "yyyy-MM-dd HH:mm:ss", $null) + + $createdDate | Should -BeGreaterOrEqual $beforeCall.AddSeconds(-1) + $createdDate | Should -BeLessOrEqual $afterCall.AddSeconds(1) + $lastModified | Should -BeGreaterOrEqual $beforeCall.AddSeconds(-1) + $lastModified | Should -BeLessOrEqual $afterCall.AddSeconds(1) + } + + It "should have identical createdDate and lastModified for new files" { + $result = New-DevSetupEnvFile + $result.devsetup.configuration.createdDate | Should -Be $result.devsetup.configuration.lastModified + } + } + + Context "Structure immutability" { + It "should return same structure on multiple calls" { + $result1 = New-DevSetupEnvFile + $result2 = New-DevSetupEnvFile + + # Compare structure (not timestamps) + $result1.devsetup.dependencies.PSObject.Properties.Name | Sort-Object | Should -Be ($result2.devsetup.dependencies.PSObject.Properties.Name | Sort-Object) + $result1.devsetup.configuration.Keys | Where-Object { $_ -notin @('createdDate', 'lastModified') } | Sort-Object | Should -Be ($result2.devsetup.configuration.Keys | Where-Object { $_ -notin @('createdDate', 'lastModified') } | Sort-Object) + } + } + + Context "Data types validation" { + It "should use correct data types for all fields" { + $result = New-DevSetupEnvFile + + # Root structure + $result | Should -BeOfType [PSCustomObject] + $result.devsetup | Should -BeOfType [PSCustomObject] + + # Dependencies + $result.devsetup.dependencies | Should -BeOfType [PSCustomObject] + $result.devsetup.dependencies.chocolatey | Should -BeOfType [System.Collections.Hashtable] + $result.devsetup.dependencies.powershell | Should -BeOfType [System.Collections.Hashtable] + $result.devsetup.dependencies.scoop | Should -BeOfType [System.Collections.Hashtable] + $result.devsetup.dependencies.homebrew | Should -BeOfType [System.Collections.Hashtable] + + # Test arrays by their Count property (empty arrays can be $null in PowerShell) + $result.devsetup.dependencies.chocolatey.packages.Count | Should -Be 0 + $result.devsetup.dependencies.powershell.modules.Count | Should -Be 0 + $result.devsetup.dependencies.scoop.packages.Count | Should -Be 0 + $result.devsetup.dependencies.scoop.buckets.Count | Should -Be 0 + $result.devsetup.dependencies.homebrew.packages.Count | Should -Be 0 + $result.devsetup.commands.Count | Should -Be 0 + + # Configuration + $result.devsetup.configuration | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result.devsetup.configuration.os | Should -BeOfType [PSCustomObject] + $result.devsetup.configuration.powershell | Should -BeOfType [PSCustomObject] + + # String fields + $result.devsetup.configuration.description | Should -BeOfType [System.String] + $result.devsetup.configuration.version | Should -BeOfType [System.String] + $result.devsetup.configuration.createdDate | Should -BeOfType [System.String] + $result.devsetup.configuration.lastModified | Should -BeOfType [System.String] + $result.devsetup.dependencies.powershell.scope | Should -BeOfType [System.String] + } + } +} diff --git a/DevSetup/Private/Utils/New-DevSetupEnvFile.ps1 b/DevSetup/Private/Utils/New-DevSetupEnvFile.ps1 new file mode 100644 index 0000000..593be3b --- /dev/null +++ b/DevSetup/Private/Utils/New-DevSetupEnvFile.ps1 @@ -0,0 +1,43 @@ +Function New-DevSetupEnvFile { + [CmdletBinding()] + [OutputType([PSCustomObject])] + Param() + + return [PSCustomObject][ordered]@{ + devsetup = [PSCustomObject][ordered]@{ + dependencies = [PSCustomObject][ordered]@{ + chocolatey = @{ + packages = @() + } + powershell = @{ + modules = @() + scope = "CurrentUser" + } + scoop = @{ + packages = @() + buckets = @() + } + homebrew = @{ + packages = @() + } + } + commands = @() + configuration = [ordered]@{ + description = "Auto-generated development environment configuration" + version = "1.0.0" + createdDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + lastModified = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + createdBy = $null + os = [PSCustomObject][ordered]@{ + name = $null + version = $null + architecture = $null + } + powershell = [PSCustomObject][ordered]@{ + version = $PSVersionTable.PSVersion.ToString() + edition = $PSVersionTable.PSEdition + } + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Optimize-DevSetupEnvs.Tests.ps1 b/DevSetup/Private/Utils/Optimize-DevSetupEnvs.Tests.ps1 index e531397..888c154 100644 --- a/DevSetup/Private/Utils/Optimize-DevSetupEnvs.Tests.ps1 +++ b/DevSetup/Private/Utils/Optimize-DevSetupEnvs.Tests.ps1 @@ -5,7 +5,7 @@ BeforeAll { . $PSScriptRoot\Get-DevSetupEnvPath.ps1 . $PSScriptRoot\Get-DevSetupPath.ps1 . $PSScriptRoot\Write-StatusMessage.ps1 - . $PSScriptRoot\Read-ConfigurationFile.ps1 + . $PSScriptRoot\Read-DevSetupEnvFile.ps1 Mock Get-DevSetupEnvPath { "$TestDrive\DevSetupEnvs" } Mock Get-DevSetupPath { "$TestDrive\DevSetup" } Mock Join-Path { Param($Path, $ChildPath) "$Path\$ChildPath" } @@ -16,7 +16,7 @@ BeforeAll { Mock Write-Host { } Mock ConvertTo-Json { param($obj) "json-output" } Mock Out-File { } - Mock Read-ConfigurationFile { + Mock Read-DevSetupEnvFile { param($Config) switch ($Config) { "$TestDrive\DevSetupEnvs\env1.yaml" { @@ -88,7 +88,7 @@ Describe "Optimize-DevSetupEnvs" { @{ Name = "bad.yaml"; FullName = "$TestDrive\DevSetupEnvs\bad.yaml" } ) } - Mock Read-ConfigurationFile { + Mock Read-DevSetupEnvFile { param($Config) if ($Config -eq "$TestDrive\DevSetupEnvs\bad.yaml") { throw "YAML error" } @{ devsetup = @{ configuration = @{ os = @{ name = "Windows" }; version = "1.0.0" } } } diff --git a/DevSetup/Private/Utils/Optimize-DevSetupEnvs.ps1 b/DevSetup/Private/Utils/Optimize-DevSetupEnvs.ps1 index 8273bbe..3be8e77 100644 --- a/DevSetup/Private/Utils/Optimize-DevSetupEnvs.ps1 +++ b/DevSetup/Private/Utils/Optimize-DevSetupEnvs.ps1 @@ -29,7 +29,7 @@ Write-Debug "Processing: $($devsetupEnvFile.Name)" # Read the YAML configuration - $config = Read-ConfigurationFile -Config $devsetupEnvFile.FullName + $config = Read-DevSetupEnvFile -Config $devsetupEnvFile.FullName # Extract environment name (filename without extension) $envName = [System.IO.Path]::GetFileNameWithoutExtension($devsetupEnvFile.Name) diff --git a/DevSetup/Private/Utils/Read-ConfigurationFile.Tests.ps1 b/DevSetup/Private/Utils/Read-ConfigurationFile.Tests.ps1 deleted file mode 100644 index 9ebed88..0000000 --- a/DevSetup/Private/Utils/Read-ConfigurationFile.Tests.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -BeforeAll { - function ConvertFrom-Yaml { } - . $PSScriptRoot\Read-ConfigurationFile.ps1 - Mock Get-Content { } - Mock ConvertFrom-Yaml { } -} - -Describe "Read-ConfigurationFile" { - - Context "When configuration file exists and contains valid YAML" { - It "Should return parsed YAML data" { - Mock Get-Content { "key: value" } - Mock ConvertFrom-Yaml { @{ key = "value" } } - $result = Read-ConfigurationFile -Config "config.yaml" - $result | Should -BeOfType System.Collections.Hashtable - $result.key | Should -Be "value" - } - } - - Context "When configuration file does not exist" { - It "Should throw an error" { - Mock Get-Content { throw "File not found" } - { Read-ConfigurationFile -Config "missing.yaml" } | Should -Throw "File not found" - } - } - - Context "When YAML is invalid" { - It "Should throw an error from ConvertFrom-Yaml" { - Mock Get-Content { "invalid: yaml: -" } - Mock ConvertFrom-Yaml { throw "Invalid YAML" } - { Read-ConfigurationFile -Config "bad.yaml" } | Should -Throw "Invalid YAML" - } - } - - Context "When ConvertFrom-Yaml returns $null" { - It "Should return null" { - Mock Get-Content { "key: value" } - Mock ConvertFrom-Yaml { $null } - $result = Read-ConfigurationFile -Config "config.yaml" - $result | Should -Be $null - } - } -} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Read-ConfigurationFile.ps1 b/DevSetup/Private/Utils/Read-ConfigurationFile.ps1 deleted file mode 100644 index 8a39e57..0000000 --- a/DevSetup/Private/Utils/Read-ConfigurationFile.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -Function Read-ConfigurationFile { - param ( - [string]$Config - ) - $YamlData = ConvertFrom-Yaml (Get-Content -Path $Config -Raw) - return $YamlData -} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Read-DevSetupEnvFile.Tests.ps1 b/DevSetup/Private/Utils/Read-DevSetupEnvFile.Tests.ps1 new file mode 100644 index 0000000..d787206 --- /dev/null +++ b/DevSetup/Private/Utils/Read-DevSetupEnvFile.Tests.ps1 @@ -0,0 +1,80 @@ +BeforeAll { + function ConvertFrom-Yaml { } + function Assert-DevSetupEnvValid { } + . $PSScriptRoot\Read-DevSetupEnvFile.ps1 + . $PSScriptRoot\Assert-DevSetupEnvValid.ps1 + Mock Get-Content { } + Mock ConvertFrom-Yaml { } + Mock Assert-DevSetupEnvValid { $true } +} + +Describe "Read-DevSetupEnvFile" { + + Context "When configuration file exists and contains valid YAML" { + It "Should return parsed YAML data after validation" { + $validYamlData = @{ devsetup = @{ configuration = @{}; dependencies = @{}; commands = @() } } + Mock Get-Content { "valid yaml content" } + Mock ConvertFrom-Yaml { $validYamlData } + Mock Assert-DevSetupEnvValid { } # Don't return anything, just don't throw + + $result = Read-DevSetupEnvFile -Config "config.yaml" + + $result | Should -BeOfType System.Collections.Hashtable + $result.devsetup | Should -Not -BeNullOrEmpty + Assert-MockCalled Assert-DevSetupEnvValid -Exactly 1 -Scope It -ParameterFilter { $EnvData -eq $validYamlData } + } + } + + Context "When configuration file does not exist" { + It "Should throw an error" { + Mock Get-Content { throw "File not found" } + { Read-DevSetupEnvFile -Config "missing.yaml" } | Should -Throw "File not found" + } + } + + Context "When YAML is invalid" { + It "Should throw an error from ConvertFrom-Yaml" { + Mock Get-Content { "invalid: yaml: -" } + Mock ConvertFrom-Yaml { throw "Invalid YAML" } + { Read-DevSetupEnvFile -Config "bad.yaml" } | Should -Throw "Invalid YAML" + } + } + + Context "When ConvertFrom-Yaml returns null" { + It "Should throw configuration error for null data" { + Mock Get-Content { "key: value" } + Mock ConvertFrom-Yaml { $null } + + { Read-DevSetupEnvFile -Config "config.yaml" } | Should -Throw "Configuration file 'config.yaml' is empty or returned null data." + } + } + + Context "When YAML structure is invalid" { + It "Should throw validation error for missing devsetup section" { + Mock Get-Content { "somekey: value" } + Mock ConvertFrom-Yaml { @{ somekey = "value" } } + Mock Assert-DevSetupEnvValid { throw "Environment data must contain 'devsetup' key." } + + { Read-DevSetupEnvFile -Config "config.yaml" } | Should -Throw "Environment data must contain 'devsetup' key." + Assert-MockCalled Assert-DevSetupEnvValid -Exactly 1 -Scope It + } + + It "Should throw validation error for malformed devsetup structure" { + Mock Get-Content { "devsetup: invalid" } + Mock ConvertFrom-Yaml { @{ devsetup = "invalid" } } + Mock Assert-DevSetupEnvValid { throw "'devsetup' must be a hashtable or PSCustomObject." } + + { Read-DevSetupEnvFile -Config "config.yaml" } | Should -Throw "'devsetup' must be a hashtable or PSCustomObject." + Assert-MockCalled Assert-DevSetupEnvValid -Exactly 1 -Scope It + } + + It "Should throw validation error for missing required sections" { + Mock Get-Content { "devsetup: {}" } + Mock ConvertFrom-Yaml { @{ devsetup = @{} } } + Mock Assert-DevSetupEnvValid { throw "Environment data 'devsetup' section must contain 'configuration' key." } + + { Read-DevSetupEnvFile -Config "config.yaml" } | Should -Throw "Environment data 'devsetup' section must contain 'configuration' key." + Assert-MockCalled Assert-DevSetupEnvValid -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Read-DevSetupEnvFile.ps1 b/DevSetup/Private/Utils/Read-DevSetupEnvFile.ps1 new file mode 100644 index 0000000..2be6682 --- /dev/null +++ b/DevSetup/Private/Utils/Read-DevSetupEnvFile.ps1 @@ -0,0 +1,17 @@ +Function Read-DevSetupEnvFile { + param ( + [string]$Config + ) + + $YamlData = ConvertFrom-Yaml -Ordered (Get-Content -Path $Config -Raw) + + # Handle null case - validation function expects non-null input + if ($null -eq $YamlData) { + throw "Configuration file '$Config' is empty or returned null data." + } + + # Validate the structure before returning - this will throw if invalid + Assert-DevSetupEnvValid -EnvData $YamlData + + return $YamlData +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Start-DevSetupSelfUpdate.ps1 b/DevSetup/Private/Utils/Start-DevSetupSelfUpdate.ps1 deleted file mode 100644 index dfff6e6..0000000 --- a/DevSetup/Private/Utils/Start-DevSetupSelfUpdate.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -Function Start-DevSetupSelfUpdate { - [CmdletBinding()] - Param() - - $manifest = Get-DevSetupManifest - if($null -eq $manifest) { - throw "Failed to load manifest file" - } - - $communityEnvironmentsProjectUri = $manifest.PrivateData.PSData.EnvironmentsProjectUri - $devsetupProjectUri = $manifest.PrivateData.PSData.ProjectUri - - $currentVersion = Get-DevSetupVersion - - $devsetupCurrentReleaseInfo = Get-GitHubRelease -Uri $devsetupProjectUri | Select-Object -First 1 - $communityEnvironmentsCurrentReleaseInfo = Get-GitHubRelease -Uri $communityEnvironmentsProjectUri | Select-Object -First 1 - - $devsetupCurrentReleaseVersion = [Version]::new(($devsetupCurrentReleaseInfo.name -Replace "v")) - if($currentVersion -lt $devsetupCurrentReleaseVersion) { - - } -} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Test-HasSudoAccess.Tests.ps1 b/DevSetup/Private/Utils/Test-HasSudoAccess.Tests.ps1 new file mode 100644 index 0000000..747b448 --- /dev/null +++ b/DevSetup/Private/Utils/Test-HasSudoAccess.Tests.ps1 @@ -0,0 +1,51 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Test-HasSudoAccess.ps1") + Mock Invoke-Command { } +} + +Describe "Test-HasSudoAccess" { + + Context "When sudo access is available" { + It "Should return true" { + Mock Invoke-Command { $script:LASTEXITCODE = 0 } + $result = Test-HasSudoAccess + $result | Should -Be $true + } + } + + Context "When sudo access is not available" { + It "Should return false" { + Mock Invoke-Command { $script:LASTEXITCODE = 1 } + $result = Test-HasSudoAccess + $result | Should -Be $false + } + } + + Context "When Invoke-Command fails" { + It "Should return false" { + Mock Invoke-Command { throw "Invoke-Command failed" } + $result = Test-HasSudoAccess + $result | Should -Be $false + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Invoke-Command { $script:LASTEXITCODE = 0 } + $result = Test-HasSudoAccess + $result | Should -Be $true + } + + It "Should work on Linux" { + Mock Invoke-Command { $script:LASTEXITCODE = 0 } + $result = Test-HasSudoAccess + $result | Should -Be $true + } + + It "Should work on macOS" { + Mock Invoke-Command { $script:LASTEXITCODE = 0 } + $result = Test-HasSudoAccess + $result | Should -Be $true + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Test-HasSudoAccess.ps1 b/DevSetup/Private/Utils/Test-HasSudoAccess.ps1 index 97d832e..1584e3c 100644 --- a/DevSetup/Private/Utils/Test-HasSudoAccess.ps1 +++ b/DevSetup/Private/Utils/Test-HasSudoAccess.ps1 @@ -1,13 +1,18 @@ Function Test-HasSudoAccess { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] [CmdletBinding()] Param( ) # Try running a harmless command with sudo - (bash -c "sudo -n true") *>$null - if ($LASTEXITCODE -eq 0) { - return $true - } else { + try { + Invoke-Command -Script { bash -c "sudo -n true" } *>$null + if ($LASTEXITCODE -eq 0) { + return $true + } else { + return $false + } + } catch { return $false } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Test-OperatingSystem.Tests.ps1 b/DevSetup/Private/Utils/Test-OperatingSystem.Tests.ps1 index 3c6c187..ed3609e 100644 --- a/DevSetup/Private/Utils/Test-OperatingSystem.Tests.ps1 +++ b/DevSetup/Private/Utils/Test-OperatingSystem.Tests.ps1 @@ -4,102 +4,99 @@ BeforeAll { } Describe "Test-OperatingSystem" { + Context "When no parameters are provided" { + It "Should return false" { + $result = Test-OperatingSystem + $result | Should -Be $false + } + } + + Context "When Powershell version is less than 6" { + BeforeAll { Mock Get-PwshVersion { [PSCustomObject]@{ Major = 5 } } } - if ($PSVersionTable.PSVersion.Major -eq 5) { - BeforeAll { Mock Get-PwshVersion { [PSCustomObject]@{ Major = $PSVersionTable.PSVersion.Major } } } - - Context "When called with -Windows on PowerShell 5.1" { - It "Should return $true" { + Context "When called with -Windows" { + It "Should return true" { $result = Test-OperatingSystem -Windows $result | Should -Be $true } } - Context "When called with -Linux on PowerShell 5.1" { - It "Should return $false" { + Context "When called with -Linux" { + It "Should return false" { $result = Test-OperatingSystem -Linux $result | Should -Be $false } } - Context "When called with -MacOS on PowerShell 5.1" { - It "Should return $false" { + Context "When called with -MacOS" { + It "Should return false" { $result = Test-OperatingSystem -MacOS $result | Should -Be $false } } - Context "When called with no parameters on PowerShell 5.1" { - It "Should return $false" { + Context "When called with no parameters" { + It "Should return false" { $result = Test-OperatingSystem $result | Should -Be $false } } } - if ($PSVersionTable.PSVersion.Major -ge 6) { - BeforeAll { Mock Get-PwshVersion { [PSCustomObject]@{ Major = $PSVersionTable.PSVersion.Major } } } - if($IsWindows) { - Context "When called in PowerShell 7+ (Windows)" { - It "Should return value of `$IsWindows (default: $true)" { - $result = Test-OperatingSystem -Windows - $result | Should -Be $true - } - It "Should return value of `$IsLinux (default: $false)" { - $result = Test-OperatingSystem -Linux - $result | Should -Be $false - } - It "Should return value of `$IsMacOS (default: $false)" { - $result = Test-OperatingSystem -MacOS - $result | Should -Be $false - } - It "Should return $false if no parameter is specified" { - $result = Test-OperatingSystem - $result | Should -Be $false - } + Context "When Powershell version is 6 or greater on windows" { + BeforeAll { + Mock Get-PwshVersion { [PSCustomObject]@{ Major = 6 } } + if($PSVersionTable.PSVersion.Major -lt 6) { + $script:IsWindows = $true + $script:IsLinux = $false + $script:IsMacOS = $false } } + It "Should return value of `$IsWindows" { + $result = Test-OperatingSystem -Windows + if ($IsWindows) { + $result | Should -Be $true + } else { + $result | Should -Be $false + } + } + } - if($IsLinux) { - Context "When called in PowerShell 7+ (Linux)" { - It "Should return value of `$IsWindows (default: $false)" { - $result = Test-OperatingSystem -Windows - $result | Should -Be $false - } - It "Should return value of `$IsLinux (default: $true)" { - $result = Test-OperatingSystem -Linux - $result | Should -Be $true - } - It "Should return value of `$IsMacOS (default: $false)" { - $result = Test-OperatingSystem -MacOS - $result | Should -Be $false - } - It "Should return $false if no parameter is specified" { - $result = Test-OperatingSystem - $result | Should -Be $false - } + Context "When Powershell version is 6 or greater on linux" { + BeforeAll { + Mock Get-PwshVersion { [PSCustomObject]@{ Major = 6 } } + if($PSVersionTable.PSVersion.Major -lt 6) { + $script:IsWindows = $false + $script:IsLinux = $true + $script:IsMacOS = $false + } + } + It "Should return value of `$IsLinux" { + $result = Test-OperatingSystem -Linux + if($IsLinux) { + $result | Should -Be $true + } else { + $result | Should -Be $false } - } - - if($IsMacOS) { - Context "When called in PowerShell 7+ (MacOS)" { - It "Should return value of `$IsWindows (default: $false)" { - $result = Test-OperatingSystem -Windows - $result | Should -Be $false - } - It "Should return value of `$IsLinux (default: $false)" { - $result = Test-OperatingSystem -Linux - $result | Should -Be $false - } - It "Should return value of `$IsMacOS (default: $true)" { - $result = Test-OperatingSystem -MacOS - $result | Should -Be $true - } - It "Should return $false if no parameter is specified" { - $result = Test-OperatingSystem - $result | Should -Be $false - } + } + } + + Context "When Powershell version is 6 or greater on macos" { + BeforeAll { + Mock Get-PwshVersion { [PSCustomObject]@{ Major = 6 } } + if($PSVersionTable.PSVersion.Major -lt 6) { + $script:IsWindows = $false + $script:IsLinux = $false + $script:IsMacOS = $true } - } + } + It "Should return value of `$IsMacOS" { + $result = Test-OperatingSystem -MacOS + if($IsMacOS) { + $result | Should -Be $true + } else { + $result | Should -Be $false + } + } } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Test-OperatingSystem.ps1 b/DevSetup/Private/Utils/Test-OperatingSystem.ps1 index b12392c..b26a0fa 100644 --- a/DevSetup/Private/Utils/Test-OperatingSystem.ps1 +++ b/DevSetup/Private/Utils/Test-OperatingSystem.ps1 @@ -13,19 +13,21 @@ Function Test-OperatingSystem { ) if((Get-PwshVersion).Major -lt 6) { - $IsPS5Windows = $true - $IsPS5Linux = $false - $IsPS5MacOS = $false + if ($Windows) { + return $true + } else { + return $false + } } if($Windows) { - return ($IsPS5Windows -or $IsWindows) + return $IsWindows } if($Linux) { - return ($IsPS5Linux -or $IsLinux) + return $IsLinux } if($MacOS) { - return ($IsPS5MacOS -or $IsMacOS) + return $IsMacOS } return $false } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Test-RunningAsAdmin.Tests.ps1 b/DevSetup/Private/Utils/Test-RunningAsAdmin.Tests.ps1 index d1cd82f..86fbdc1 100644 --- a/DevSetup/Private/Utils/Test-RunningAsAdmin.Tests.ps1 +++ b/DevSetup/Private/Utils/Test-RunningAsAdmin.Tests.ps1 @@ -1,48 +1,314 @@ BeforeAll { . $PSScriptRoot\Test-RunningAsAdmin.ps1 . $PSScriptRoot\Test-OperatingSystem.ps1 - Mock Test-OperatingSystem { param($Windows) $false } + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } # Default to Windows + Mock Invoke-Command { } + Mock New-Object { } } Describe "Test-RunningAsAdmin" { Context "When not running on Windows" { It "Should return true (assume sufficient privileges)" { - Mock Test-OperatingSystem { param($Windows) $false } + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $true } + if ($MacOS) { return $false } + } + $result = Test-RunningAsAdmin + $result | Should -Be $true + } + } + + Context "When running on Windows as administrator" { + It "Should return true" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + class MockPrincipal { + [bool] IsInRole([object]$role) { return $true } + } + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + return [PSCustomObject]@{ } + } + 1 { + $script:callCount++ + return [PSCustomObject]@{ } + } + } + } + Mock New-Object -MockWith { + param($type) + return [MockPrincipal]::new() + } $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 2 -Scope It + Assert-MockCalled 'New-Object' -Exactly 1 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It $result | Should -Be $true } } - if ($PSVersionTable.PSVersion.Major -eq 5 -or ($PSVersionTable.PSVersion.Major -eq 6 -and $IsWindows)) { - Context "When running on Windows as administrator" { - It "Should return true" { - Mock Test-OperatingSystem { param($Windows) $true } - class MockPrincipal { - [bool] IsInRole([object]$role) { return $true } + Context "When running on Windows but not as administrator" { + It "Should return false" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + class MockPrincipal { + [bool] IsInRole([object]$role) { return $false } + } + + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + return [PSCustomObject]@{ } + } + 1 { + $script:callCount++ + return [PSCustomObject]@{ } + } } - Mock 'New-Object' -MockWith { - param($type) - return [MockPrincipal]::new() + } + Mock New-Object -MockWith { + param($type) + return [MockPrincipal]::new() + } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 2 -Scope It + Assert-MockCalled 'New-Object' -Exactly 1 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $false + } + } + + Context "When running on Windows and WindowsIdentity is null" { + It "Should return false" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + return $null + } + 1 { + $script:callCount++ + return [PSCustomObject]@{ } + } } - $result = Test-RunningAsAdmin - $result | Should -Be $true } + + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 1 -Scope It + Assert-MockCalled 'New-Object' -Exactly 0 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $false } + } - Context "When running on Windows but not as administrator" { - It "Should return false" { - Mock Test-OperatingSystem { param($Windows) $true } - class MockPrincipal { - [bool] IsInRole([object]$role) { return $false } + Context "When running on Windows and WindowsBuiltInRole is null" { + It "Should return false" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + return [PSCustomObject]@{ } + } + 1 { + $script:callCount++ + return $null + } } - Mock 'New-Object' -MockWith { - param($type) - return [MockPrincipal]::new() + } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 2 -Scope It + Assert-MockCalled 'New-Object' -Exactly 0 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $false + } + } + + Context "When running on Windows and New-Object fails" { + It "Should return false" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + return [PSCustomObject]@{ } + } + 1 { + $script:callCount++ + return [PSCustomObject]@{ } + } } - $result = Test-RunningAsAdmin - $result | Should -Be $false } + Mock 'New-Object' { throw "New-Object failed" } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 2 -Scope It + Assert-MockCalled 'New-Object' -Exactly 1 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $false + } + } + + Context "When running on Windows and First Invoke-Command fails" { + It "Should return false" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + throw "Invoke-Command failed" + } + 1 { + $script:callCount++ + return [PSCustomObject]@{ } + } + } + } + Mock 'New-Object' { throw "New-Object failed" } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 1 -Scope It + Assert-MockCalled 'New-Object' -Exactly 0 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $false + } + } + + Context "When running on Windows and Second Invoke-Command fails" { + It "Should return false" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + [PSCustomObject]@{ } + } + 1 { + $script:callCount++ + throw "Invoke-Command failed" + } + } + } + Mock 'New-Object' { throw "New-Object failed" } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 2 -Scope It + Assert-MockCalled 'New-Object' -Exactly 0 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $false + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $true } + if ($Linux) { return $false } + if ($MacOS) { return $false } + } + class MockPrincipal { + [bool] IsInRole([object]$role) { return $true } + } + $script:callCount = 0 + Mock Invoke-Command { + switch ($script:callCount) { + 0 { + $script:callCount++ + return [PSCustomObject]@{ } + } + 1 { + $script:callCount++ + return [PSCustomObject]@{ } + } + } + } + Mock 'New-Object' -MockWith { + param($type) + return [MockPrincipal]::new() + } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 2 -Scope It + Assert-MockCalled 'New-Object' -Exactly 1 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $true + } + + It "Should work on Linux" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $true } + if ($MacOS) { return $false } + } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 0 -Scope It + Assert-MockCalled 'New-Object' -Exactly 0 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $true + } + + It "Should work on macOS" { + Mock Test-OperatingSystem { + Param($Windows, $Linux, $MacOS) + if ($Windows) { return $false } + if ($Linux) { return $false } + if ($MacOS) { return $true } + } + $result = Test-RunningAsAdmin + Assert-MockCalled 'Invoke-Command' -Exactly 0 -Scope It + Assert-MockCalled 'New-Object' -Exactly 0 -Scope It + Assert-MockCalled 'Test-OperatingSystem' -Exactly 1 -Scope It + $result | Should -Be $true } } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Test-RunningAsAdmin.ps1 b/DevSetup/Private/Utils/Test-RunningAsAdmin.ps1 index 85bf9d5..6e324f0 100644 --- a/DevSetup/Private/Utils/Test-RunningAsAdmin.ps1 +++ b/DevSetup/Private/Utils/Test-RunningAsAdmin.ps1 @@ -4,10 +4,22 @@ Function Test-RunningAsAdmin { # On non-Windows platforms, assume we have sufficient privileges return $true } - - $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) - if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + + try { + $WindowsIdentity = Invoke-Command { [Security.Principal.WindowsIdentity]::GetCurrent() } + if($null -eq $WindowsIdentity) { + return $false + } + $WindowsBuiltInRole = Invoke-Command { [Security.Principal.WindowsBuiltInRole]::Administrator } + if ($null -eq $WindowsBuiltInRole) { + return $false + } + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal($WindowsIdentity) + if (-not $currentPrincipal.IsInRole($WindowsBuiltInRole)) { + return $false + } + return $true + } catch { return $false - } - return $true + } } \ No newline at end of file diff --git a/DevSetup/Private/Utils/Update-DevSetupEnvFile.Tests.ps1 b/DevSetup/Private/Utils/Update-DevSetupEnvFile.Tests.ps1 new file mode 100644 index 0000000..7732cb1 --- /dev/null +++ b/DevSetup/Private/Utils/Update-DevSetupEnvFile.Tests.ps1 @@ -0,0 +1,99 @@ +BeforeAll { + Function ConvertTo-Yaml { } + . (Join-Path $PSScriptRoot "Update-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "Write-StatusMessage.ps1") + Mock Write-StatusMessage { } + Mock ConvertTo-Yaml { "mocked yaml content" } + Mock Set-Content { } +} + +Describe "Update-DevSetupEnvFile" { + + Context "When DevSetupEnvData is null" { + It "Should throw" { + { Update-DevSetupEnvFile -EnvFilePath "$TestDrive\test.env" -DevSetupEnvData $null } | Should -Throw + } + } + + Context "When DevSetupEnvData is invalid type" { + It "Should write error and return" { + Mock Test-Path { return $true } + Update-DevSetupEnvFile -EnvFilePath "$TestDrive\test.env" -DevSetupEnvData "invalid string" + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Invalid data format" -and $Verbosity -eq "Error" } + Assert-MockCalled ConvertTo-Yaml -Exactly 0 -Scope It + Assert-MockCalled Set-Content -Exactly 0 -Scope It + } + } + + Context "When ConvertTo-Yaml throws exception" { + It "Should write error and return" { + Mock Test-Path { return $true } + Mock ConvertTo-Yaml { throw "YAML conversion failed" } + Update-DevSetupEnvFile -EnvFilePath "$TestDrive\test.env" -DevSetupEnvData @{ key = "value" } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { $Verbosity -eq "Error" } + Assert-MockCalled Set-Content -Exactly 0 -Scope It + } + } + + Context "When ShouldProcess is false" { + It "Should not write to file" { + $envFile = "$TestDrive\test.env" + New-Item -ItemType File -Path $envFile + Update-DevSetupEnvFile -EnvFilePath $envFile -DevSetupEnvData @{ key = "value" } -WhatIf + Assert-MockCalled ConvertTo-Yaml -Exactly 1 -Scope It + Assert-MockCalled Set-Content -Exactly 0 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 0 -Scope It -ParameterFilter { $Message -match "Environment file updated successfully" } + } + } + + Context "When Set-Content throws exception" { + It "Should write error and return" { + Mock Set-Content { throw "Write failed" } + $envFile = "$TestDrive\test.env" + New-Item -ItemType File -Path $envFile + Update-DevSetupEnvFile -EnvFilePath $envFile -DevSetupEnvData @{ key = "value" } + Assert-MockCalled Write-StatusMessage -Exactly 2 -Scope It -ParameterFilter { $Verbosity -eq "Error" } + Assert-MockCalled Set-Content -Exactly 1 -Scope It -ParameterFilter { $Path -eq $envFile -and $Value -eq "mocked yaml content" } + } + } + + Context "When update succeeds with Hashtable" { + It "Should convert to YAML and write file" { + $envFile = "$TestDrive\test.env" + New-Item -ItemType File -Path $envFile + Update-DevSetupEnvFile -EnvFilePath $envFile -DevSetupEnvData @{ key = "value" } + Assert-MockCalled ConvertTo-Yaml -Exactly 1 -Scope It + Assert-MockCalled Set-Content -Exactly 1 -Scope It -ParameterFilter { $Path -eq $envFile -and $Value -eq "mocked yaml content" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Environment file updated successfully" -and $Verbosity -eq "Debug" } + } + } + + Context "When update succeeds with PSCustomObject" { + It "Should convert to YAML and write file" { + $envFile = "$TestDrive\test.env" + New-Item -ItemType File -Path $envFile + $data = [PSCustomObject]@{ key = "value" } + Update-DevSetupEnvFile -EnvFilePath $envFile -DevSetupEnvData $data + Assert-MockCalled ConvertTo-Yaml -Exactly 1 -Scope It + Assert-MockCalled Set-Content -Exactly 1 -Scope It -ParameterFilter { $Path -eq $envFile -and $Value -eq "mocked yaml content" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Environment file updated successfully" -and $Verbosity -eq "Debug" } + } + } + + Context "When file path is empty" { + It "Should throw" { + { Update-DevSetupEnvFile -EnvFilePath "" -DevSetupEnvData @{ key = "value" } } | Should -Throw + } + } + + Context "When data is empty Hashtable" { + It "Should process empty data" { + $envFile = "$TestDrive\test.env" + New-Item -ItemType File -Path $envFile + Update-DevSetupEnvFile -EnvFilePath $envFile -DevSetupEnvData @{} + Assert-MockCalled ConvertTo-Yaml -Exactly 1 -Scope It + Assert-MockCalled Set-Content -Exactly 1 -Scope It -ParameterFilter { $Path -eq $envFile -and $Value -eq "mocked yaml content" } + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Environment file updated successfully" -and $Verbosity -eq "Debug" } + } + } +} diff --git a/DevSetup/Private/Utils/Update-DevSetupEnvFile.ps1 b/DevSetup/Private/Utils/Update-DevSetupEnvFile.ps1 new file mode 100644 index 0000000..cab49b5 --- /dev/null +++ b/DevSetup/Private/Utils/Update-DevSetupEnvFile.ps1 @@ -0,0 +1,35 @@ +Function Update-DevSetupEnvFile { + [CmdletBinding(SupportsShouldProcess=$true)] + [OutputType([void])] + param ( + [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] + $DevSetupEnvData, + [Parameter(Mandatory=$true, Position=1)] + [string]$EnvFilePath + ) + + try { + if ($DevSetupEnvData.GetType().Name -ne 'Hashtable' -and $DevSetupEnvData.GetType().Name -ne 'PSCustomObject' -and $DevSetupEnvData.GetType().Name -ne 'OrderedDictionary') { + Write-StatusMessage "Actual type: $($DevSetupEnvData.GetType().Name)" -Verbosity Error + Write-StatusMessage "Invalid data format. Expected a Hashtable or PSCustomObject." -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + $YamlContent = ConvertTo-Yaml $DevSetupEnvData + } catch { + Write-StatusMessage "Failed to convert data to YAML format: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + if ($PSCmdlet.ShouldProcess($EnvFilePath, "Update Environment File")) { + try { + Set-Content -Path $EnvFilePath -Value $YamlContent -Encoding UTF8 -Force + Write-StatusMessage "Environment file updated successfully: $EnvFilePath" -Verbosity Debug + } catch { + Write-StatusMessage "Failed to update environment file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return + } + } + return +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Write-NewConfig.Tests.ps1 b/DevSetup/Private/Utils/Write-NewConfig.Tests.ps1 new file mode 100644 index 0000000..0f9f7d5 --- /dev/null +++ b/DevSetup/Private/Utils/Write-NewConfig.Tests.ps1 @@ -0,0 +1,445 @@ +BeforeAll { + . (Join-Path $PSScriptRoot "Write-NewConfig.ps1") + . (Join-Path $PSScriptRoot "New-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "Test-RunningAsAdmin.ps1") + . (Join-Path $PSScriptRoot "Test-OperatingSystem.ps1") + . (Join-Path $PSScriptRoot "Get-HostArchitecture.ps1") + . (Join-Path $PSScriptRoot "Get-HostOperatingSystem.ps1") + . (Join-Path $PSScriptRoot "Get-HostOperatingSystemVersion.ps1") + . (Join-Path $PSScriptRoot "Get-EnvironmentVariable.ps1") + . (Join-Path $PSScriptRoot "Read-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "Update-DevSetupEnvFile.ps1") + . (Join-Path $PSScriptRoot "Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "Optimize-DevSetupEnvs.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Chocolatey\Invoke-ChocolateyPackageExport.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Scoop\Invoke-ScoopComponentExport.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Homebrew\Invoke-HomebrewComponentsExport.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\Providers\Powershell\Invoke-PowershellModulesExport.ps1") + . (Join-Path $PSScriptRoot "..\..\..\DevSetup\Private\3rdParty\ConvertFrom-3rdPartyInstall.ps1") + Mock Test-OperatingSystem { $true } # Default to Windows for tests +} + +Describe "Write-NewConfig" { + Context "When not running as administrator" { + It "should throw an exception and return false" { + Mock Test-RunningAsAdmin { $false } + Mock Write-StatusMessage { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Error creating new configuration:" } + } + } + + Context "When creating a new configuration file" { + It "should create base config and export packages" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $false } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + Assert-MockCalled Test-RunningAsAdmin -Exactly 1 -Scope It + Assert-MockCalled Test-Path -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + $result | Should -Be $true + } + } + + Context "When updating an existing configuration file" { + It "should merge with existing config and increment version" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ version = "1.0.0"; description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + } + } + + Context "When exporting chocolately packages and the export returns false" { + It "should report and continue" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ version = "1.0.0"; description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $false } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to convert Chocolatey packages, but continuing..." } + } + } + + Context "When exporting scoop packages and the export returns false" { + It "should report and continue" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ version = "1.0.0"; description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $false } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to convert Scoop packages, but continuing..." } + } + } + + Context "When exporting powershell modules and the export returns false" { + It "should report and continue" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ version = "1.0.0"; description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $false } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to convert PowerShell modules, but continuing..." } + } + } + + Context "When updating an existing configuration file and version is invalid" { + It "should keep version" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ version = "abcd"; description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "- Version" -and $Verbosity -eq "Warning"} + } + } + + Context "When updating an existing configuration file and version is not present" { + It "should skip version" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "- Version" } + } + } + + Context "When updating existing configuration with lastModified field" { + It "should preserve existing lastModified timestamp" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ version = "1.0.0"; description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser"; lastModified = "2022-06-15 14:30:00" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + } + } + + Context "When reading existing config fails" { + It "should fall back to new config" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { throw "Read failed" } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + } + } + + Context "When writing YAML fails" { + It "should return false" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $false } + Mock Update-DevSetupEnvFile { throw "YAML conversion failed" } + Mock Write-StatusMessage { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $false + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to create base configuration file" } + } + } + + Context "When DryRun is specified on non-Windows" { + BeforeEach { + $script:callCount = 0 + Mock Test-OperatingSystem { + switch ($script:callCount) { + 0 { $script:callCount++; return $false } # First call for Windows check + 1 { $script:callCount++; return $false } + } + } + } # Default to non-Windows for this context + It "should pass DryRun to Homebrew export" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Linux" } + Mock Get-HostOperatingSystemVersion { "Ubuntu 20.04" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $false } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { Write-Output $false } # Not Windows + Mock Invoke-HomebrewComponentsExport { + Param($Config, $DryRun) + return $true + } + Mock Invoke-PowershellModulesExport { return $true } + Mock Invoke-ChocolateyPackageExport { return $false } + Mock Invoke-ScoopComponentExport { return $false } + Mock ConvertFrom-3rdPartyInstall { return $true } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" -DryRun:$true + Assert-MockCalled Test-OperatingSystem -Exactly 2 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 0 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 0 -Scope It + Assert-MockCalled Invoke-HomebrewComponentsExport -Exactly 1 -Scope It -ParameterFilter { $WhatIf -eq $true } + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + $result | Should -Be $true + } + } + + Context "Cross-platform compatibility" { + It "should work on Windows" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $false } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + } + + It "should work on Linux" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Linux" } + Mock Get-HostOperatingSystemVersion { "Ubuntu 20.04" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $false } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $false } # Not Windows + Mock Invoke-HomebrewComponentsExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Invoke-HomebrewComponentsExport -Exactly 1 -Scope It + } + + It "should work on Linux and when export homebrew returns false it should continue" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Linux" } + Mock Get-HostOperatingSystemVersion { "Ubuntu 20.04" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $false } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $false } # Not Windows + Mock Invoke-HomebrewComponentsExport { $false } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Invoke-HomebrewComponentsExport -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "Failed to convert Homebrew packages, but continuing..." } + } + + It "should work on macOS" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "arm64" } + Mock Get-HostOperatingSystem { "macOS" } + Mock Get-HostOperatingSystemVersion { "12.0.1" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $false } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $false } # Not Windows + Mock Invoke-HomebrewComponentsExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Invoke-HomebrewComponentsExport -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Write-NewConfig.ps1 b/DevSetup/Private/Utils/Write-NewConfig.ps1 index bdb3f93..13dda52 100644 --- a/DevSetup/Private/Utils/Write-NewConfig.ps1 +++ b/DevSetup/Private/Utils/Write-NewConfig.ps1 @@ -2,6 +2,7 @@ Function Write-NewConfig { Param( [Parameter(Mandatory = $true)] [string]$OutFile, + [Parameter(Mandatory = $false)] [switch]$DryRun = $false ) @@ -11,126 +12,28 @@ Function Write-NewConfig { throw "This operation requires administrator privileges. Please run as administrator." } - # Create base config file - #Write-Host "Creating base configuration file: $OutFile" -ForegroundColor Cyan - - # Get OS information in a PowerShell 5.1 compatible way - $platform = [System.Environment]::OSVersion.Platform.ToString() - $osArchitecture = if ([System.Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } - - # Make platform more user-friendly - $friendlyPlatform = switch ($platform) { - "Win32NT" { "Windows" } - "Unix" { - # Check if it's macOS or Linux in a PS 5.1 compatible way - $uname = "" - try { - $uname = (& uname -s 2>$null) - } catch {} - if ($uname -eq "Darwin") { - "macOS" - } else { - "Linux" - } - } - default { $platform } - } - - # Get friendly OS version - $friendlyOsVersion = switch ($platform) { - "Win32NT" { - try { - $osInfo = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue - if ($osInfo) { - $osInfo.Caption -replace "Microsoft ", "" - } else { - [System.Environment]::OSVersion.VersionString - } - } - catch { - [System.Environment]::OSVersion.VersionString - } - } - "Unix" { - if ($friendlyPlatform -eq "macOS") { - try { - $macVersion = (& sw_vers -productVersion 2>$null) - if ($macVersion) { - "macOS $macVersion" - } else { - [System.Environment]::OSVersion.VersionString - } - } - catch { - [System.Environment]::OSVersion.VersionString - } - } else { - # Linux - try { - $linuxVersion = "" - if (Test-Path "/etc/os-release") { - $osRelease = Get-Content "/etc/os-release" | Where-Object { $_ -like "PRETTY_NAME=*" } - if ($osRelease) { - $linuxVersion = ($osRelease -split '=')[1] -replace '"', '' - } - } - if ($linuxVersion) { - $linuxVersion - } else { - [System.Environment]::OSVersion.VersionString - } - } - catch { - [System.Environment]::OSVersion.VersionString - } - } - } - default { - [System.Environment]::OSVersion.VersionString - } + $osArchitecture = (Get-HostArchitecture) + $friendlyPlatform = (Get-HostOperatingSystem) + $friendlyOsVersion = (Get-HostOperatingSystemVersion) + $username = "Unknown" + if((Test-OperatingSystem -Windows)) { + $username = (Get-EnvironmentVariable USERNAME) + } else { + $username = (Get-EnvironmentVariable USER) } - - $username = if ($env:USERNAME) { $env:USERNAME } elseif ($env:USER) { $env:USER } else { "Unknown" } # Handle versioning and preserve existing config $currentVersion = "1.0.0" # Default version for new files - $baseConfig = @{ - devsetup = @{ - dependencies = @{ - chocolatey = @{ - packages = @() - } - powershell = @{ - modules = @() - scope = "CurrentUser" - } - scoop = @{ - packages = @() - buckets = @() - } - } - commands = @() - configuration = @{ - description = "Auto-generated development environment configuration" - version = $currentVersion - createdDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - createdBy = $username - os = @{ - name = $friendlyPlatform - version = $friendlyOsVersion - architecture = $osArchitecture - } - powershell = @{ - version = $PSVersionTable.PSVersion.ToString() - edition = $PSVersionTable.PSEdition - } - } - } - } - + $baseConfig = New-DevSetupEnvFile + $baseConfig.devsetup.configuration.version = $currentVersion + $baseConfig.devsetup.configuration.createdBy = $username + $baseConfig.devsetup.configuration.os.name = $friendlyPlatform + $baseConfig.devsetup.configuration.os.version = $friendlyOsVersion + $baseConfig.devsetup.configuration.os.architecture = $osArchitecture + if (Test-Path $OutFile) { try { - Write-Host "- Using existing configuration..." -ForegroundColor Gray - $existingConfig = Read-ConfigurationFile -Config $OutFile + Write-StatusMessage "- Using existing configuration..." -ForegroundColor Gray + $existingConfig = Read-DevSetupEnvFile -Config $OutFile if ($existingConfig -and $existingConfig.devsetup) { # Preserve existing dependencies if ($existingConfig.devsetup.dependencies) { @@ -152,13 +55,13 @@ Function Write-NewConfig { $newMinor = $existingVersion.Minor + 1 $currentVersion = "$($existingVersion.Major).$newMinor.$($existingVersion.Build)" $baseConfig.devsetup.configuration.version = $currentVersion - Write-Host "- Version: $existingVersionString -> $currentVersion" -ForegroundColor Gray + Write-StatusMessage "- Version: $existingVersionString -> $currentVersion" -ForegroundColor Gray } catch { - Write-Warning "- Version: $currentVersion" + Write-StatusMessage "- Version: $currentVersion" -Verbosity Warning } } else { - Write-Host "- Version: $currentVersion" -ForegroundColor Gray + Write-StatusMessage "- Version: $currentVersion" -ForegroundColor Gray } # Preserve other configuration fields but update system info @@ -168,66 +71,70 @@ Function Write-NewConfig { if ($existingConfig.devsetup.configuration.createdDate) { # Keep original creation date, but we could add a lastModified field $baseConfig.devsetup.configuration.createdDate = $existingConfig.devsetup.configuration.createdDate + } + if($existingConfig.devsetup.configuration.lastModified) { + $baseConfig.devsetup.configuration.lastModified = $existingConfig.devsetup.configuration.lastModified + } else { $baseConfig.devsetup.configuration.lastModified = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") } } } } catch { - Write-Warning "Failed to read existing configuration for merging: $_" - Write-Host "- Using new configuration with default version: $currentVersion" -ForegroundColor Gray + Write-StatusMessage "Failed to read existing configuration for merging: $_" -Verbosity Warning + Write-StatusMessage "- Using new configuration with default version: $currentVersion" -ForegroundColor Gray } } else { - Write-Host "- Using new configuration file, starting with version: $currentVersion" -ForegroundColor Green + Write-StatusMessage "- Using new configuration file, starting with version: $currentVersion" -ForegroundColor Green } try { - $yamlOutput = $baseConfig | ConvertTo-Yaml - $yamlOutput | Out-File -FilePath $OutFile -Encoding UTF8 - Write-Debug "Base configuration file created successfully!" + $baseConfig | Update-DevSetupEnvFile -EnvFilePath $OutFile -WhatIf:$DryRun + Write-StatusMessage "Base configuration file created successfully!" -Verbosity Debug } catch { - Write-Error "Failed to create base configuration file: $_" + Write-StatusMessage "Failed to create base configuration file: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } if((Test-OperatingSystem -Windows)) { # Convert from installed Chocolatey packages - Write-Host "`nScanning installed Chocolatey packages..." -ForegroundColor Cyan - if (-not (Export-InstalledChocolateyPackages -Config $OutFile)) { - Write-Warning "Failed to convert Chocolatey packages, but continuing..." + Write-StatusMessage "`nScanning installed Chocolatey packages..." -ForegroundColor Cyan + if (-not (Invoke-ChocolateyPackageExport -Config $OutFile -DryRun:$DryRun)) { + Write-StatusMessage "Failed to convert Chocolatey packages, but continuing..." -Verbosity Warning } # Convert from installed Scoop packages - Write-Host "`nScanning installed Scoop packages..." -ForegroundColor Cyan - if (-not (Export-InstalledScoopPackages -Config $OutFile)) { - Write-Warning "Failed to convert Scoop packages, but continuing..." + Write-StatusMessage "`nScanning installed Scoop packages..." -ForegroundColor Cyan + if (-not (Invoke-ScoopComponentExport -Config $OutFile -DryRun:$DryRun)) { + Write-StatusMessage "Failed to convert Scoop packages, but continuing..." -Verbosity Warning } } else { # Convert from installed Homebrew packages - Write-Host "`nScanning installed Homebrew packages..." -ForegroundColor Cyan - if (-not (Invoke-HomebrewComponentExport -Config $OutFile -DryRun:$DryRun)) { - Write-Warning "Failed to convert Homebrew packages, but continuing..." + Write-StatusMessage "`nScanning installed Homebrew packages..." -ForegroundColor Cyan + if (-not (Invoke-HomebrewComponentsExport -Config $OutFile -WhatIf:$DryRun)) { + Write-StatusMessage "Failed to convert Homebrew packages, but continuing..." -Verbosity Warning } } # Convert from installed PowerShell modules - Write-Host "`nScanning installed PowerShell modules..." -ForegroundColor Cyan - if (-not (Export-InstalledPowershellModules -Config $OutFile)) { - Write-Warning "Failed to convert PowerShell modules, but continuing..." + Write-StatusMessage "`nScanning installed PowerShell modules..." -ForegroundColor Cyan + if (-not (Invoke-PowershellModulesExport -Config $OutFile -DryRun:$DryRun)) { + Write-StatusMessage "Failed to convert PowerShell modules, but continuing..." -Verbosity Warning } - ConvertFrom-3rdPartyInstall -Config $OutFile + ConvertFrom-3rdPartyInstall -Config $OutFile -DryRun:$DryRun | Out-Null - Write-Host "`nConfiguration file generation completed!" -ForegroundColor Green - Write-Host "- Configuration saved to: $OutFile" -ForegroundColor Gray - Write-Host "" + Write-StatusMessage "`nConfiguration file generation completed!" -ForegroundColor Green + Write-StatusMessage "- Configuration saved to: $OutFile`n" -ForegroundColor Gray - Optimize-DevSetupEnvs + Optimize-DevSetupEnvs | Out-Null return $true } catch { - Write-Error "Error creating new configuration: $_" + Write-StatusMessage "Error creating new configuration: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } } \ No newline at end of file diff --git a/DevSetup/Public/Use-DevSetup.Tests.ps1 b/DevSetup/Public/Use-DevSetup.Tests.ps1 new file mode 100644 index 0000000..4ec76e0 --- /dev/null +++ b/DevSetup/Public/Use-DevSetup.Tests.ps1 @@ -0,0 +1,342 @@ +BeforeAll { + function Write-EZLog { } + . (Join-Path $PSScriptRoot "Use-DevSetup.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Utils\Get-DevSetupVersion.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Utils\Get-DevSetupLogPath.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Install-DevSetupEnv.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Update-DevSetup.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Initialize-DevSetup.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Export-DevSetupEnv.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Show-DevSetupEnvList.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Uninstall-DevSetupEnv.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Show-ExplainDevSetupEnv.ps1") + Mock Write-Host { } + Mock Write-StatusMessage { } + Mock Write-Error { } + Mock Write-Verbose { } + Mock Write-Debug { } +} + +Describe "Use-DevSetup" { + Context "When installing from name" { + It "should call Install-DevSetupEnv with correct parameters" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { $true } + + $result = Use-DevSetup -Install -Name "TestEnv" + $result | Should -Be $true + Assert-MockCalled Install-DevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Name -eq "TestEnv" } + } + } + + Context "When installing from URL" { + It "should call Install-DevSetupEnv with URL parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { $true } + + $result = Use-DevSetup -Install -Url "https://example.com/config.yaml" + $result | Should -Be $true + Assert-MockCalled Install-DevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Url -eq "https://example.com/config.yaml" } + } + } + + Context "When installing from path" { + It "should call Install-DevSetupEnv with Path parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { $true } + + $result = Use-DevSetup -Install -Path "C:\Configs\test.yaml" + $result | Should -Be $true + Assert-MockCalled Install-DevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Path -eq "C:\Configs\test.yaml" } + } + } + + Context "When updating to main" { + It "should call Update-DevSetup with Main parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Update-DevSetup { } + + $result = Use-DevSetup -Update -Main + $result | Should -Be $null + Assert-MockCalled Update-DevSetup -Exactly 1 -Scope It -ParameterFilter { $Main -eq $true } + } + } + + Context "When updating to develop" { + It "should call Update-DevSetup with Develop parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Update-DevSetup { } + + $result = Use-DevSetup -Update -Develop + $result | Should -Be $null + Assert-MockCalled Update-DevSetup -Exactly 1 -Scope It -ParameterFilter { $Develop -eq $true } + } + } + + Context "When updating to specific version" { + It "should call Update-DevSetup with Version parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Update-DevSetup { } + + $result = Use-DevSetup -Update -Version "1.0.8" + $result | Should -Be $null + Assert-MockCalled Update-DevSetup -Exactly 1 -Scope It -ParameterFilter { $Version -eq "1.0.8" } + } + } + + Context "When updating without specific branch or version" { + It "should call Update-DevSetup with Version set to 'latest'" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Update-DevSetup { } + + $result = Use-DevSetup -Update + $result | Should -Be $null + Assert-MockCalled Update-DevSetup -Exactly 1 -Scope It -ParameterFilter { $Version -eq "latest" } + } + } + + Context "When initializing" { + It "should call Initialize-DevSetup" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Initialize-DevSetup { } + + $result = Use-DevSetup -Init + $result | Should -Be $null + Assert-MockCalled Initialize-DevSetup -Exactly 1 -Scope It + } + } + + Context "When exporting with name" { + It "should call Export-DevSetupEnv with Name parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Export-DevSetupEnv { $true } + + $result = Use-DevSetup -Export -Name "MyEnv" + $result | Should -Be $true + Assert-MockCalled Export-DevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Name -eq "MyEnv" } + } + } + + Context "When exporting to path" { + It "should call Export-DevSetupEnv with Path parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Export-DevSetupEnv { $true } + + $result = Use-DevSetup -Export -Path "C:\Exports\env.yaml" + $result | Should -Be $true + Assert-MockCalled Export-DevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Path -eq "C:\Exports\env.yaml" } + } + } + + Context "When listing all" { + It "should call Show-DevSetupEnvList without filters" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-DevSetupEnvList { } + + $result = Use-DevSetup -List + $result | Should -Be $null + Assert-MockCalled Show-DevSetupEnvList -Exactly 1 -Scope It + } + } + + Context "When listing by platform" { + It "should call Show-DevSetupEnvList with Platform parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-DevSetupEnvList { } + + $result = Use-DevSetup -List -Platform "Linux" + $result | Should -Be $null + Assert-MockCalled Show-DevSetupEnvList -Exactly 1 -Scope It -ParameterFilter { $Platform -eq "Linux" } + } + } + + Context "When listing by provider" { + It "should call Show-DevSetupEnvList with Provider parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-EZLog { } + Mock Show-DevSetupEnvList { } + + $result = Use-DevSetup -List -Provider "Chocolatey" + $result | Should -Be $null + Assert-MockCalled Show-DevSetupEnvList -Exactly 1 -Scope It -ParameterFilter { $Provider -eq "Chocolatey" } + } + } + + Context "When listing by provider and platform" { + It "should call Show-DevSetupEnvList with Provider and Platform parameters" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-DevSetupEnvList { } + + $result = Use-DevSetup -List -Provider "Chocolatey" -Platform "Windows" + $result | Should -Be $null + Assert-MockCalled Show-DevSetupEnvList -Exactly 1 -Scope It -ParameterFilter { $Provider -eq "Chocolatey" -and $Platform -eq "Windows" } + } + } + + Context "When uninstalling" { + It "should call Uninstall-DevSetupEnv with Name parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Uninstall-DevSetupEnv { $true } + + $result = Use-DevSetup -Uninstall -Name "TestEnv" + $result | Should -Be $true + Assert-MockCalled Uninstall-DevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Name -eq "TestEnv" } + } + } + + Context "When explaining with name" { + It "should call Show-ExplainDevSetupEnv with Name parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-ExplainDevSetupEnv { $true } + + $result = Use-DevSetup -Explain -Name "TestEnv" + $result | Should -Be $true + Assert-MockCalled Show-ExplainDevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Name -eq "TestEnv" } + } + } + + Context "When explaining from path" { + It "should call Show-ExplainDevSetupEnv with Path parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-ExplainDevSetupEnv { $true } + + $result = Use-DevSetup -Explain -Path "C:\Configs\test.yaml" + $result | Should -Be $true + Assert-MockCalled Show-ExplainDevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Path -eq "C:\Configs\test.yaml" } + } + } + + Context "When an error occurs" { + It "should handle exceptions and log errors" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { throw "Installation failed" } + + { Use-DevSetup -Install -Name "TestEnv" } | Should -Not -Throw # Function handles exceptions internally + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Error executing DevSetup action" } + } + } + + Context "When DryRun is specified" { + It "should pass DryRun to Install-DevSetupEnv" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { $true } + + $result = Use-DevSetup -Install -Name "TestEnv" -DryRun + $result | Should -Be $true + Assert-MockCalled Install-DevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $DryRun -eq $true } + } + } + + Context "Cross-platform compatibility" { + It "should work on Windows" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { $true } + + $result = Use-DevSetup -Install -Name "TestEnv" + $result | Should -Be $true + } + + It "should work on Linux" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { $true } + + $result = Use-DevSetup -Install -Name "TestEnv" + $result | Should -Be $true + } + + It "should work on macOS" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Install-DevSetupEnv { $true } + + $result = Use-DevSetup -Install -Name "TestEnv" + $result | Should -Be $true + } + } +} \ No newline at end of file diff --git a/DevSetup/Public/Use-DevSetup.ps1 b/DevSetup/Public/Use-DevSetup.ps1 index 99b0d28..ce85d75 100644 --- a/DevSetup/Public/Use-DevSetup.ps1 +++ b/DevSetup/Public/Use-DevSetup.ps1 @@ -154,10 +154,15 @@ Function Use-DevSetup { [Parameter(Mandatory = $true, ParameterSetName = "Uninstall")] [switch]$Uninstall, + + [Parameter(Mandatory = $true, ParameterSetName = "Explain")] + [Parameter(Mandatory = $true, ParameterSetName = "ExplainPath")] + [switch]$Explain, [Parameter(Mandatory = $true, ParameterSetName = "Install")] [Parameter(Mandatory = $true, ParameterSetName = "Export")] [Parameter(Mandatory = $true, ParameterSetName = "Uninstall")] + [Parameter(Mandatory = $true, ParameterSetName = "Explain")] [string]$Name, [Parameter(Mandatory = $true, ParameterSetName = "InstallUrl")] @@ -165,6 +170,7 @@ Function Use-DevSetup { [Parameter(Mandatory = $true, ParameterSetName = "InstallPath")] [Parameter(Mandatory = $true, ParameterSetName = "ExportPath")] + [Parameter(Mandatory = $true, ParameterSetName = "ExplainPath")] [string]$Path, [Parameter(Mandatory = $false)] @@ -196,132 +202,132 @@ Function Use-DevSetup { $sp = "$v" + (Format-RepeatChar " " 118) + "$v" Write-Host "" - Write-Host "$tb" -ForegroundColor Cyan - Write-Host "$sp" -ForegroundColor Cyan - Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor Cyan -NoNewLine + Write-Host "$tb" -ForegroundColor DarkCyan + Write-Host "$sp" -ForegroundColor DarkCyan + Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr " -ForegroundColor Cyan -NoNewLine + Write-Host "$tr " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr " -ForegroundColor Cyan -NoNewLine + Write-Host "$tr " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr " -ForegroundColor Cyan -NoNewLine + Write-Host "$tr " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" (Format-RepeatChar " " 24) "$v" -ForegroundColor Cyan + Write-Host "$tr" (Format-RepeatChar " " 24) "$v" -ForegroundColor DarkCyan - Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor Cyan -NoNewLine + Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h$h$h$br" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h$h$h$br" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h$h$h$br" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h$h$h$br" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h$h$h$br$bl$h$h" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h$h$h$br$bl$h$h" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h$br" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h$br" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" (Format-RepeatChar " " 23) "$v" -ForegroundColor Cyan + Write-Host "$tr" (Format-RepeatChar " " 23) "$v" -ForegroundColor DarkCyan - Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor Cyan -NoNewLine + Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr " -ForegroundColor Cyan -NoNewLine + Write-Host "$tr " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr " -ForegroundColor Cyan -NoNewLine + Write-Host "$tr " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$br" (Format-RepeatChar " " 23) "$v" -ForegroundColor Cyan + Write-Host "$tl$br" (Format-RepeatChar " " 23) "$v" -ForegroundColor DarkCyan - Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor Cyan -NoNewLine + Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h$br $bl" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h$br $bl" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr " -ForegroundColor Cyan -NoNewLine + Write-Host "$tr " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$br$bl$h$h$h$h" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$br$bl$h$h$h$h" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h$br " -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$h$h$br " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v " -ForegroundColor Cyan -NoNewLine + Write-Host "$v " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$h$h$h$br" (Format-RepeatChar " " 24) "$v" -ForegroundColor Cyan + Write-Host "$tl$h$h$h$br" (Format-RepeatChar " " 24) "$v" -ForegroundColor DarkCyan - Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor Cyan -NoNewLine + Write-Host "$v" (Format-RepeatChar " " 25) -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$br" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$br" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr $bl" -ForegroundColor Cyan -NoNewLine + Write-Host "$tr $bl" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$br " -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$br " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" -ForegroundColor Cyan -NoNewLine + Write-Host "$v" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tr " -ForegroundColor Cyan -NoNewLine + Write-Host "$tr " -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v $bl" -ForegroundColor Cyan -NoNewLine + Write-Host "$v $bl" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b$b$b$b$b" -ForegroundColor White -NoNewLine - Write-Host "$tl$br" -ForegroundColor Cyan -NoNewLine + Write-Host "$tl$br" -ForegroundColor DarkCyan -NoNewLine Write-Host "$b$b" -ForegroundColor White -NoNewLine - Write-Host "$v" (Format-RepeatChar " " 28) "$v" -ForegroundColor Cyan + Write-Host "$v" (Format-RepeatChar " " 28) "$v" -ForegroundColor DarkCyan - Write-Host "$v" (Format-RepeatChar " " 24) "$bl$h$h$h$h$h$br $bl$h$h$h$h$h$h$br $bl$h$h$h$br $bl$h$h$h$h$h$h$br$bl$h$h$h$h$h$h$br $bl$h$br $bl$h$h$h$h$h$br $bl$h$br" (Format-RepeatChar " " 28) "$v" -ForegroundColor Cyan + Write-Host "$v" (Format-RepeatChar " " 24) "$bl$h$h$h$h$h$br $bl$h$h$h$h$h$h$br $bl$h$h$h$br $bl$h$h$h$h$h$h$br$bl$h$h$h$h$h$h$br $bl$h$br $bl$h$h$h$h$h$br $bl$h$br" (Format-RepeatChar " " 28) "$v" -ForegroundColor DarkCyan - Write-Host "$v" -ForegroundColor Cyan -NoNewline + Write-Host "$v" -ForegroundColor DarkCyan -NoNewline $version = Get-DevSetupVersion -Local $versionDisplay = "Development Environment Manager v$version" $paddedAction = $versionDisplay.PadLeft(($versionDisplay.Length + 118) / 2).PadRight(118) Write-Host "$paddedAction" -ForegroundColor White -NoNewline - Write-Host "$v" -ForegroundColor Cyan - Write-Host "$sp" -ForegroundColor Cyan - Write-Host "$bm" -ForegroundColor Cyan + Write-Host "$v" -ForegroundColor DarkCyan + Write-Host "$sp" -ForegroundColor DarkCyan + Write-Host "$bm" -ForegroundColor DarkCyan $actionDisplay = switch ($selectedAction) { @@ -340,13 +346,15 @@ Function Use-DevSetup { 'listplatform' { ">> LISTING Available Environments From Platform" } 'listproviderplatform' { ">> LISTING Available Environments From Provider and Platform" } 'uninstall' { ">> UNINSTALLING Development Environment" } + 'explain' { ">> EXPLAINING Environment" } + 'explainpath' { ">> EXPLAINING Environment From Path" } } $paddedAction = $actionDisplay.PadLeft(($actionDisplay.Length + 118) / 2).PadRight(118) - Write-Host "$v" -ForegroundColor Cyan -NoNewline + Write-Host "$v" -ForegroundColor DarkCyan -NoNewline Write-Host "$paddedAction" -ForegroundColor Yellow -NoNewline - Write-Host "$v" -ForegroundColor Cyan - Write-Host "$bb" -ForegroundColor Cyan + Write-Host "$v" -ForegroundColor DarkCyan + Write-Host "$bb" -ForegroundColor DarkCyan Write-Host "" $RunDate = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" @@ -368,7 +376,7 @@ Function Use-DevSetup { $ParameterCopy = [hashtable]$PSBoundParameters $ParameterCopy.Remove('Update') if($_ -eq 'update') { - $ParameterCopy['Latest'] = $true + $ParameterCopy['Version'] = "latest" } Update-DevSetup @ParameterCopy | Out-Null } @@ -393,9 +401,13 @@ Function Use-DevSetup { $ParameterCopy.Remove('Uninstall') Uninstall-DevSetupEnv @ParameterCopy } + { $_ -eq 'explain' -or $_ -eq 'explainpath' } { + Write-StatusMessage "Explaining development environment..." -ForegroundColor Yellow + $ParameterCopy = [hashtable]$PSBoundParameters + $ParameterCopy.Remove('Explain') + Show-ExplainDevSetupEnv @ParameterCopy + } } - - #Write-Host "DevSetup action '$selectedAction' completed successfully!" -ForegroundColor Green } catch { Write-StatusMessage "Error executing DevSetup action '$selectedAction': $_" -Verbosity Error diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b66c0e9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,292 @@ +# Security Policy + +## Overview + +DevSetup is a PowerShell module that automates development environment setup by installing packages and executing commands. We take security seriously and have implemented multiple layers of protection to ensure safe usage. + +## Supported Versions + +We actively maintain and provide security updates for the following versions: + +| Version | Supported | +| ------- | ------------------ | +| 1.0.6+ | :white_check_mark: | +| < 1.0 | :x: | + +## Security Features + +### Built-in Security Measures + +#### 1. **Configuration Validation** +- All YAML configuration files are validated using `Assert-DevSetupEnvValid` +- Schema validation prevents malicious or malformed configurations +- Input sanitization for all user-provided parameters + +#### 2. **WhatIf/Confirm Support** +- All destructive operations support `-WhatIf` parameter for safe testing +- Users can preview changes before execution using dry-run functionality +- Confirmation prompts for potentially dangerous operations + +#### 3. **Secure Command Execution** +- Commands are executed in controlled contexts with proper error handling +- No arbitrary code execution from untrusted sources +- Parameter validation and sanitization for all external commands + +#### 4. **Provider Security** +- Package installations use official package managers (Chocolatey, Scoop, PowerShell Gallery) +- Version pinning support to prevent supply chain attacks +- Verification of package sources and integrity + +#### 5. **Logging and Auditing** +- Comprehensive logging of all operations via `Write-StatusMessage` +- Stack trace logging for debugging and security analysis +- Optional detailed logging with `Write-EZLog` + +### Security Analysis + +The project includes automated security analysis: + +```powershell +# Run security analysis +.\runSecurity.ps1 +``` + +This script performs: +- PowerShell Script Analyzer (PSScriptAnalyzer) security rule checks +- Code quality and security best practice validation +- Detection of common security anti-patterns + +## Reporting Security Vulnerabilities + +We appreciate the security research community's efforts to improve the security of our project. If you believe you have found a security vulnerability in DevSetup, please report it responsibly. + +### Reporting Process + +1. **Do not** create a public GitHub issue for security vulnerabilities +2. **Do** email security reports to: [security@pwshdevs.com](mailto:security@pwshdevs.com) +3. Include the following information: + - Description of the vulnerability + - Steps to reproduce the issue + - Potential impact assessment + - Suggested mitigation (if known) + - Your contact information + +### Response Timeline + +- **Acknowledgment**: Within 48 hours of report receipt +- **Initial Assessment**: Within 5 business days +- **Status Updates**: Weekly until resolution +- **Resolution**: Target within 30 days for high-severity issues + +### Disclosure Policy + +- We follow responsible disclosure practices +- We will work with reporters to understand and address issues +- Public disclosure will be coordinated after fixes are available +- Credit will be given to reporters (if desired) in security advisories + +## Security Best Practices for Users + +### Safe Configuration Management + +#### 1. **Source Control Security** +```yaml +# โœ… Good: Specific versions and trusted sources +dependencies: + scoop: + packages: + - name: "git" + version: "2.41.0" + bucket: "main" + +# โŒ Avoid: Unspecified versions or untrusted sources +dependencies: + scoop: + packages: + - name: "git" # No version specified + bucket: "unknown-bucket" # Untrusted source +``` + +#### 2. **Command Security** +```yaml +# โœ… Good: Specific, well-defined commands +commands: + - packageName: "git-config" + command: "git" + params: + config: + - "--global user.name 'Your Name'" + - "--global user.email 'you@example.com'" + +# โŒ Avoid: Arbitrary or complex command chains +commands: + - packageName: "dangerous" + command: "powershell -ExecutionPolicy Bypass -Command 'iex (irm https://untrusted.com/script.ps1)'" +``` + +### Environment File Security + +#### 1. **File Validation** +- Always validate environment files before use: + ```powershell + # Test configuration before installation + devsetup -Install -Name "my-env" -DryRun + ``` + +#### 2. **Source Verification** +- Only use environment files from trusted sources +- Review all commands and packages before execution +- Verify checksums when downloading from URLs + +#### 3. **Access Control** +- Store environment files in secure locations +- Use appropriate file permissions +- Avoid storing secrets in plain text + +### Network Security + +#### 1. **HTTPS Usage** +- Always use HTTPS URLs for remote environment files +- Verify SSL certificates are valid +- Use trusted mirror sources for packages + +#### 2. **Firewall Considerations** +- Package managers may require internet access +- Consider corporate proxy configurations +- Monitor network traffic during installations + +### Execution Environment + +#### 1. **Privilege Management** +- Run with minimum required privileges +- Avoid unnecessary administrative rights +- Use PowerShell execution policy appropriately + +#### 2. **Isolation** +- Test in isolated environments when possible +- Use containers or VMs for untrusted configurations +- Maintain separate environments for different projects + +## Common Security Scenarios + +### Scenario 1: Untrusted Environment File +**Risk**: Malicious commands or packages in configuration +**Mitigation**: +```powershell +# Always review and test first +Get-Content "untrusted.devsetup" | Out-Host +devsetup -Install -Path "untrusted.devsetup" -DryRun +``` + +### Scenario 2: Supply Chain Attack +**Risk**: Compromised packages from official repositories +**Mitigation**: +- Pin specific package versions +- Monitor security advisories for used packages +- Use package verification when available + +### Scenario 3: Command Injection +**Risk**: Malicious commands in YAML configuration +**Mitigation**: +- DevSetup validates all inputs through schema validation +- Commands are executed in controlled contexts +- No shell interpretation of user input + +### Scenario 4: Privilege Escalation +**Risk**: Unnecessary elevation of privileges +**Mitigation**: +- Most operations don't require administrative privileges +- Package managers handle elevation appropriately +- Use `-DryRun` to preview required permissions + +## Security Checklist for Contributors + +When contributing to DevSetup, please ensure: + +- [ ] **Input Validation**: All user inputs are properly validated +- [ ] **Error Handling**: Comprehensive try/catch blocks with secure error messages +- [ ] **Logging**: Appropriate logging without exposing sensitive information +- [ ] **Testing**: Security scenarios are included in test suites +- [ ] **Documentation**: Security implications are documented +- [ ] **Dependencies**: New dependencies are from trusted sources +- [ ] **Permissions**: Minimal required permissions are used +- [ ] **WhatIf Support**: Destructive operations support dry-run mode + +## Security Testing + +### Automated Testing +The project includes security-focused tests: +```powershell +# Run tests with security focus +Invoke-Pester -Path "DevSetup\**\*.Tests.ps1" -Tag "Security" + +# Test error handling and edge cases +Invoke-Pester -Path "DevSetup\**\*.Tests.ps1" -Tag "ErrorHandling" +``` + +### Manual Security Testing +1. **Configuration Validation**: + - Test with malformed YAML files + - Verify handling of missing/invalid properties + - Check parameter validation + +2. **Command Execution**: + - Test with invalid commands + - Verify proper error handling + - Check for information disclosure + +3. **File Operations**: + - Test with non-existent paths + - Verify access control respect + - Check for path traversal issues + +## Incident Response + +If you believe your system has been compromised through the use of DevSetup: + +1. **Immediate Actions**: + - Isolate the affected system + - Document the incident details + - Preserve logs and evidence + +2. **Assessment**: + - Review recent DevSetup usage + - Check installed packages and executed commands + - Analyze system logs for anomalies + +3. **Recovery**: + - Remove or quarantine suspicious packages + - Reset affected configurations + - Update to the latest DevSetup version + +4. **Reporting**: + - Report the incident to the DevSetup team + - Share lessons learned with the community (if appropriate) + +## Resources + +### Security Tools +- [PowerShell Script Analyzer](https://github.com/PowerShell/PSScriptAnalyzer) - Static analysis tool +- [Chocolatey Security](https://docs.chocolatey.org/en-us/features/security) - Package security features +- [Scoop Security](https://github.com/ScoopInstaller/Scoop/wiki/Security) - Scoop security documentation + +### Security Guidelines +- [PowerShell Security Best Practices](https://docs.microsoft.com/en-us/powershell/scripting/learn/security/powershell-security-best-practices) +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) - Web application security risks +- [CIS Controls](https://www.cisecurity.org/controls/) - Cybersecurity framework + +### Community Resources +- [PowerShell Security Forum](https://github.com/PowerShell/PowerShell/discussions/categories/security) +- [DevSec Community](https://dev-sec.io/) - DevOps security resources + +## Contact Information + +- **Security Issues**: [security@pwshdevs.com](mailto:security@pwshdevs.com) +- **General Questions**: [GitHub Discussions](https://github.com/pwshdevs/devsetup/discussions) +- **Documentation**: [Project Website](https://www.pwshdevs.com/docs/devsetup/) + +--- + +**Note**: This security policy is a living document and will be updated as the project evolves. Please check back regularly for updates. + +Last Updated: September 2025 \ No newline at end of file diff --git a/coverage.xml b/coverage.xml deleted file mode 100644 index 3c8a67e..0000000 --- a/coverage.xml +++ /dev/null @@ -1,3783 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/generateCoverageReport.ps1 b/generateCoverageReport.ps1 new file mode 100644 index 0000000..289b416 --- /dev/null +++ b/generateCoverageReport.ps1 @@ -0,0 +1 @@ +& (Join-Path $env:UserProfile '\.dotnet\tools\reportgenerator.exe') -sourcedirs:"DevSetup" -reports:"coverage.xml" -targetdir:"..\reports" -reporttypes:MarkdownSummaryGithub \ No newline at end of file diff --git a/preCommit.ps1 b/preCommit.ps1 new file mode 100644 index 0000000..8a014fe --- /dev/null +++ b/preCommit.ps1 @@ -0,0 +1,62 @@ +Import-Module Pester -ErrorAction Stop +$modifiedFiles = (git status -u -s -b | Where-Object { -not ($_ -match "^\s+D") } | Foreach-Object { $_.Substring(3) } | Where-Object { ($_ -match "^DevSetup*") -and -not ($_ -match ".Tests.ps1") }) +if ($modifiedFiles.Count -gt 0) { + Write-Host "The following DevSetup files have been modified:" -ForegroundColor DarkCyan + $modifiedFiles | ForEach-Object { Write-Host "- $_" -ForegroundColor DarkGray } + Write-Host "" + foreach ($file in $modifiedFiles) { + # Check to see if file has a .Tests.ps1 counterpart + $testFile = $file -replace '\.ps1$', '.Tests.ps1' + if (Test-Path $testFile) { + Write-Host "Running tests for $file..." -ForegroundColor DarkCyan + $TestData = ((Invoke-Pester $testFile -CodeCoverage $file -PassThru -Quiet) 2>$null 3>$null 4>$null 5>$null 6>$null) + if($TestData.PassedCount -gt 0) { + $passedColor = "DarkGreen" + } else { + $passedColor = "DarkGray" + } + + if($TestData.FailedCount -gt 0) { + $failedColor = "DarkRed" + } else { + $failedColor = "DarkGray" + } + + if($TestData.SkippedCount -gt 0) { + $skippedColor = "DarkYellow" + } else { + $skippedColor = "DarkGray" + } + + if($TestData.Failed) { + $TestData.Failed | ForEach-Object { Write-Host $_ -ForegroundColor DarkRed } + } + + Write-Host "Tests Passed: $($TestData.PassedCount)," -NoNewLine -ForegroundColor $passedColor + Write-Host " Failed: $($TestData.FailedCount)," -NoNewLine -ForegroundColor $failedColor + Write-Host " Skipped: $($TestData.SkippedCount)," -NoNewline -ForegroundColor $skippedColor + Write-Host " Inconclusive: $($TestData.InconclusiveCount), NotRun: $($TestData.NotRunCount)" -ForegroundColor DarkGray + $Report = $TestData.CodeCoverage.CoverageReport + $Coverage = $TestData.CodeCoverage.CoveragePercent + $Target = $TestData.CodeCoverage.CoveragePercentTarget + if($null -ne $Coverage -and $null -ne $Target) { + if($Coverage -lt $Target) { + $Color = "DarkRed" + } else { + $Color = "DarkGreen" + } + } else { + $Color = "DarkGray" + } + if($Report) { + $Report -Split "`n" | Select-Object -First 1 | Foreach-Object { Write-Host $_ -ForegroundColor $Color } + $Report -Split "`n" | Select-Object -Skip 1 | ForEach-Object { Write-Host $_ -ForegroundColor Gray } + } + } else { + Write-Host "No tests found for $file`n" -ForegroundColor DarkRed + } + } + +} else { + Write-Host "No modified DevSetup files detected." +} \ No newline at end of file diff --git a/runTests.ps1 b/runTests.ps1 index 19cf388..df3f89f 100644 --- a/runTests.ps1 +++ b/runTests.ps1 @@ -1,6 +1,13 @@ $config = New-PesterConfiguration #$config.Run.PassThru = $true +$config.Run.Path = "DevSetup" +$config.CodeCoverage.Path = "DevSetup" +$config.CodeCoverage.OutputFormat = "JaCoCo" +$config.CodeCoverage.OutputPath = "coverage.xml" +$config.Output.Verbosity = "Minimal" $config.CodeCoverage.Enabled = $true $config.TestResult.Enabled = $true #$config.Output.Verbosity = "GithubActions" -Invoke-Pester -Configuration $config \ No newline at end of file +Invoke-Pester -Configuration $config + +# & 'C:\Users\TestUser\.dotnet\tools\reportgenerator.exe' -reports:"coverage.xml" -targetdir:"." -reporttypes:MarkdownSummaryGithub \ No newline at end of file diff --git a/testResults.xml b/testResults.xml deleted file mode 100644 index ce10472..0000000 --- a/testResults.xml +++ /dev/null @@ -1,2039 +0,0 @@ -๏ปฟ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file