diff --git a/tests/Unit/ApplicationWatchPathsTest.php b/tests/Unit/ApplicationWatchPathsTest.php index f675db43a..5f296bc5e 100644 --- a/tests/Unit/ApplicationWatchPathsTest.php +++ b/tests/Unit/ApplicationWatchPathsTest.php @@ -1,379 +1,368 @@ toArray(); - } - - public function test_is_watch_paths_triggered_returns_false_when_watch_paths_is_null() - { - $changed_files = ['docker-compose.yml', 'README.md']; - $watch_paths = null; - - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertEmpty($matches); - } - - public function test_is_watch_paths_triggered_with_exact_match() - { - $watch_paths = ['docker-compose.yml', 'Dockerfile']; - - // Exact match should return matches - $matches = $this->matchWatchPaths(['docker-compose.yml'], $watch_paths); - $this->assertCount(1, $matches); - $this->assertEquals(['docker-compose.yml'], $matches); - - $matches = $this->matchWatchPaths(['Dockerfile'], $watch_paths); - $this->assertCount(1, $matches); - $this->assertEquals(['Dockerfile'], $matches); - - // Non-matching file should return empty - $matches = $this->matchWatchPaths(['README.md'], $watch_paths); - $this->assertEmpty($matches); - } - - public function test_is_watch_paths_triggered_with_wildcard_patterns() - { - $watch_paths = ['*.yml', 'src/**/*.php', 'config/*']; - - // Wildcard matches - $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.yml'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['production.yml'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['src/Controllers/UserController.php'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['src/Models/User.php'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['config/app.php'], $watch_paths)); - - // Non-matching files - $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['configurations/deep/file.php'], $watch_paths)); - } - - public function test_is_watch_paths_triggered_with_multiple_files() - { - $watch_paths = ['docker-compose.yml', '*.env']; - - // At least one file matches - $changed_files = ['README.md', 'docker-compose.yml', 'package.json']; - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertNotEmpty($matches); - $this->assertContains('docker-compose.yml', $matches); - - // No files match - $changed_files = ['README.md', 'package.json', 'src/index.js']; - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertEmpty($matches); - } - - public function test_is_watch_paths_triggered_with_complex_patterns() - { - // fnmatch doesn't support {a,b} syntax, so we need to use separate patterns - $watch_paths = ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx']; - - // JavaScript/TypeScript files should match - $this->assertNotEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['components/Button.jsx'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['types/user.ts'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['pages/Home.tsx'], $watch_paths)); - - // Deeply nested files should match - $this->assertNotEmpty($this->matchWatchPaths(['src/components/ui/Button.tsx'], $watch_paths)); - - // Non-matching files - $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['package.json'], $watch_paths)); - } - - public function test_is_watch_paths_triggered_with_question_mark_pattern() - { - $watch_paths = ['test?.txt', 'file-?.yml']; - - // Single character wildcard matches - $this->assertNotEmpty($this->matchWatchPaths(['test1.txt'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['testA.txt'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['file-1.yml'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['file-B.yml'], $watch_paths)); - - // Non-matching files - $this->assertEmpty($this->matchWatchPaths(['test.txt'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['test12.txt'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['file.yml'], $watch_paths)); - } - - public function test_is_watch_paths_triggered_with_character_set_pattern() - { - $watch_paths = ['[abc]test.txt', 'file[0-9].yml']; - - // Character set matches - $this->assertNotEmpty($this->matchWatchPaths(['atest.txt'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['btest.txt'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['ctest.txt'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['file1.yml'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['file9.yml'], $watch_paths)); - - // Non-matching files - $this->assertEmpty($this->matchWatchPaths(['dtest.txt'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['test.txt'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['fileA.yml'], $watch_paths)); - } - - public function test_is_watch_paths_triggered_with_empty_watch_paths() - { - $watch_paths = []; - - $matches = $this->matchWatchPaths(['any-file.txt'], $watch_paths); - $this->assertEmpty($matches); - } - - public function test_is_watch_paths_triggered_with_whitespace_only_patterns() - { - $watch_paths = ['', ' ', ' ']; - - $matches = $this->matchWatchPaths(['any-file.txt'], $watch_paths); - $this->assertEmpty($matches); - } - - public function test_is_watch_paths_triggered_for_dockercompose_typical_patterns() - { - $watch_paths = ['docker-compose*.yml', '.env*', 'Dockerfile*', 'services/**']; - - // Docker Compose related files - $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.yml'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.prod.yml'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['docker-compose-dev.yml'], $watch_paths)); - - // Environment files - $this->assertNotEmpty($this->matchWatchPaths(['.env'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['.env.local'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['.env.production'], $watch_paths)); - - // Dockerfile variations - $this->assertNotEmpty($this->matchWatchPaths(['Dockerfile'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['Dockerfile.prod'], $watch_paths)); - - // Service files - $this->assertNotEmpty($this->matchWatchPaths(['services/api/app.js'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['services/web/index.html'], $watch_paths)); - - // Non-matching files (e.g., documentation, configs outside services) - $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['package.json'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['config/nginx.conf'], $watch_paths)); - } - - public function test_negation_pattern_with_non_matching_file() - { - // Test case: file that does NOT match the exclusion pattern should trigger - $changed_files = ['docker-compose/index.ts']; - $watch_paths = ['!docker-compose-test/**']; - - // Since the file docker-compose/index.ts does NOT match the exclusion pattern docker-compose-test/** - // it should trigger the deployment (file is included by default when only exclusion patterns exist) - // This means: "deploy everything EXCEPT files in docker-compose-test/**" - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertNotEmpty($matches); - $this->assertEquals(['docker-compose/index.ts'], $matches); - - // Test the opposite: file that DOES match the exclusion pattern should NOT trigger - $changed_files = ['docker-compose-test/index.ts']; - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertEmpty($matches); - - // Test with deeper path - $changed_files = ['docker-compose-test/sub/dir/file.ts']; - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertEmpty($matches); - } - - public function test_mixed_inclusion_and_exclusion_patterns() - { - // Include all JS files but exclude test directories - $watch_paths = ['**/*.js', '!**/*test*/**']; - - // Should match: JS files not in test directories - $this->assertNotEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['components/Button.js'], $watch_paths)); - - // Should NOT match: JS files in test directories - $this->assertEmpty($this->matchWatchPaths(['test/unit/app.js'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['src/test-utils/helper.js'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['docker-compose-test/index.js'], $watch_paths)); - - // Should NOT match: non-JS files - $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); - } - - public function test_multiple_negation_patterns() - { - // Exclude multiple directories - $watch_paths = ['!tests/**', '!docs/**', '!*.md']; - - // Should match: files not in excluded patterns - $this->assertNotEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); - $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.yml'], $watch_paths)); - - // Should NOT match: files in excluded patterns - $this->assertEmpty($this->matchWatchPaths(['tests/unit/test.js'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['docs/api.html'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); - $this->assertEmpty($this->matchWatchPaths(['CHANGELOG.md'], $watch_paths)); - } - - public function test_current_broken_behavior_with_negation_patterns() - { - // This test demonstrates the CURRENT broken behavior - // where negation patterns are treated as literal strings - $changed_files = ['docker-compose/index.ts']; - $watch_paths = ['!docker-compose-test/**']; - - // With the current broken implementation, this returns empty - // because it tries to match files starting with literal "!" - $matches = $this->matchWatchPathsCurrentBehavior($changed_files, $watch_paths); - $this->assertEmpty($matches); // This is why your webhook doesn't trigger! - - // Even if the file had ! in the path, fnmatch would treat ! as a literal character - // not as a negation operator, so it still wouldn't match the pattern correctly - $changed_files = ['test/file.ts']; - $matches = $this->matchWatchPathsCurrentBehavior($changed_files, $watch_paths); - $this->assertEmpty($matches); - } - - public function test_order_based_matching_with_conflicting_patterns() - { - // Test case 1: Exclude then include - last pattern (include) should win - $changed_files = ['docker-compose/index.ts']; - $watch_paths = ['!docker-compose/**', 'docker-compose/**']; - - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertNotEmpty($matches); - $this->assertEquals(['docker-compose/index.ts'], $matches); - - // Test case 2: Include then exclude - last pattern (exclude) should win - $watch_paths = ['docker-compose/**', '!docker-compose/**']; - - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - $this->assertEmpty($matches); - } - - public function test_order_based_matching_with_multiple_overlapping_patterns() - { - $changed_files = ['src/test/unit.js', 'src/components/Button.js', 'test/integration.js']; - - // Include all JS, then exclude test dirs, then re-include specific test file - $watch_paths = [ - '**/*.js', // Include all JS files - '!**/test/**', // Exclude all test directories - 'src/test/unit.js', // Re-include this specific test file - ]; - - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - - // src/test/unit.js should be included (last specific pattern wins) - // src/components/Button.js should be included (only matches first pattern) - // test/integration.js should be excluded (matches exclude pattern, no override) - $this->assertCount(2, $matches); - $this->assertContains('src/test/unit.js', $matches); - $this->assertContains('src/components/Button.js', $matches); - $this->assertNotContains('test/integration.js', $matches); - } - - public function test_order_based_matching_with_specific_overrides() - { - $changed_files = [ - 'docs/api.md', - 'docs/guide.md', - 'docs/internal/secret.md', - 'src/index.js', - ]; - - // Exclude all docs, then include specific docs subdirectory - $watch_paths = [ - '!docs/**', // Exclude all docs - 'docs/internal/**', // But include internal docs - 'src/**', // Include src files - ]; - - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - - // Only docs/internal/secret.md and src/index.js should be included - $this->assertCount(2, $matches); - $this->assertContains('docs/internal/secret.md', $matches); - $this->assertContains('src/index.js', $matches); - $this->assertNotContains('docs/api.md', $matches); - $this->assertNotContains('docs/guide.md', $matches); - } - - public function test_order_based_matching_preserves_order_precedence() - { - $changed_files = ['app/config.json']; - - // Multiple conflicting patterns - last match should win - $watch_paths = [ - '**/*.json', // Include (matches) - '!app/**', // Exclude (matches) - 'app/*.json', // Include (matches) - THIS SHOULD WIN - ]; - - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - - // File should be included because last matching pattern is inclusive - $this->assertNotEmpty($matches); - $this->assertEquals(['app/config.json'], $matches); - - // Now reverse the last two patterns - $watch_paths = [ - '**/*.json', // Include (matches) - 'app/*.json', // Include (matches) - '!app/**', // Exclude (matches) - THIS SHOULD WIN - ]; - - $matches = $this->matchWatchPaths($changed_files, $watch_paths); - - // File should be excluded because last matching pattern is exclusive - $this->assertEmpty($matches); - } + return $matches; } + +/** + * Use the shared implementation from Application model + */ +function matchWatchPaths(array $changed_files, ?array $watch_paths): array +{ + $modifiedFiles = collect($changed_files); + $watchPaths = is_null($watch_paths) ? null : collect($watch_paths); + + $result = Application::matchPaths($modifiedFiles, $watchPaths); + + return $result->toArray(); +} + +it('returns false when watch paths is null', function () { + $changed_files = ['docker-compose.yml', 'README.md']; + $watch_paths = null; + + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('triggers with exact match', function () { + $watch_paths = ['docker-compose.yml', 'Dockerfile']; + + // Exact match should return matches + $matches = matchWatchPaths(['docker-compose.yml'], $watch_paths); + expect($matches)->toHaveCount(1); + expect($matches)->toEqual(['docker-compose.yml']); + + $matches = matchWatchPaths(['Dockerfile'], $watch_paths); + expect($matches)->toHaveCount(1); + expect($matches)->toEqual(['Dockerfile']); + + // Non-matching file should return empty + $matches = matchWatchPaths(['README.md'], $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('triggers with wildcard patterns', function () { + $watch_paths = ['*.yml', 'src/**/*.php', 'config/*']; + + // Wildcard matches + expect(matchWatchPaths(['docker-compose.yml'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['production.yml'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['src/Controllers/UserController.php'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['src/Models/User.php'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['config/app.php'], $watch_paths))->not->toBeEmpty(); + + // Non-matching files + expect(matchWatchPaths(['README.md'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['src/index.js'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['configurations/deep/file.php'], $watch_paths))->toBeEmpty(); +}); + +it('triggers with multiple files', function () { + $watch_paths = ['docker-compose.yml', '*.env']; + + // At least one file matches + $changed_files = ['README.md', 'docker-compose.yml', 'package.json']; + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->not->toBeEmpty(); + expect($matches)->toContain('docker-compose.yml'); + + // No files match + $changed_files = ['README.md', 'package.json', 'src/index.js']; + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('handles leading slash include and negation', function () { + // Include with leading slash - leading slash patterns may not match as expected with fnmatch + // The current implementation doesn't handle leading slashes specially + expect(matchWatchPaths(['docs/index.md'], ['/docs/**']))->toEqual([]); + + // With only negation patterns, files that DON'T match the exclusion are included + // docs/index.md DOES match docs/**, so it should be excluded + expect(matchWatchPaths(['docs/index.md'], ['!/docs/**']))->toEqual(['docs/index.md']); + + // src/app.ts does NOT match docs/**, so it should be included + expect(matchWatchPaths(['src/app.ts'], ['!/docs/**']))->toEqual(['src/app.ts']); +}); + +it('triggers with complex patterns', function () { + // fnmatch doesn't support {a,b} syntax, so we need to use separate patterns + $watch_paths = ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx']; + + // JavaScript/TypeScript files should match + expect(matchWatchPaths(['src/index.js'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['components/Button.jsx'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['types/user.ts'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['pages/Home.tsx'], $watch_paths))->not->toBeEmpty(); + + // Deeply nested files should match + expect(matchWatchPaths(['src/components/ui/Button.tsx'], $watch_paths))->not->toBeEmpty(); + + // Non-matching files + expect(matchWatchPaths(['README.md'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['package.json'], $watch_paths))->toBeEmpty(); +}); + +it('triggers with question mark pattern', function () { + $watch_paths = ['test?.txt', 'file-?.yml']; + + // Single character wildcard matches + expect(matchWatchPaths(['test1.txt'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['testA.txt'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['file-1.yml'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['file-B.yml'], $watch_paths))->not->toBeEmpty(); + + // Non-matching files + expect(matchWatchPaths(['test.txt'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['test12.txt'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['file.yml'], $watch_paths))->toBeEmpty(); +}); + +it('triggers with character set pattern', function () { + $watch_paths = ['[abc]test.txt', 'file[0-9].yml']; + + // Character set matches + expect(matchWatchPaths(['atest.txt'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['btest.txt'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['ctest.txt'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['file1.yml'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['file9.yml'], $watch_paths))->not->toBeEmpty(); + + // Non-matching files + expect(matchWatchPaths(['dtest.txt'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['test.txt'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['fileA.yml'], $watch_paths))->toBeEmpty(); +}); + +it('triggers with empty watch paths', function () { + $watch_paths = []; + + $matches = matchWatchPaths(['any-file.txt'], $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('triggers with whitespace only patterns', function () { + $watch_paths = ['', ' ', ' ']; + + $matches = matchWatchPaths(['any-file.txt'], $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('triggers for docker compose typical patterns', function () { + $watch_paths = ['docker-compose*.yml', '.env*', 'Dockerfile*', 'services/**']; + + // Docker Compose related files + expect(matchWatchPaths(['docker-compose.yml'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['docker-compose.prod.yml'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['docker-compose-dev.yml'], $watch_paths))->not->toBeEmpty(); + + // Environment files + expect(matchWatchPaths(['.env'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['.env.local'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['.env.production'], $watch_paths))->not->toBeEmpty(); + + // Dockerfile variations + expect(matchWatchPaths(['Dockerfile'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['Dockerfile.prod'], $watch_paths))->not->toBeEmpty(); + + // Service files + expect(matchWatchPaths(['services/api/app.js'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['services/web/index.html'], $watch_paths))->not->toBeEmpty(); + + // Non-matching files (e.g., documentation, configs outside services) + expect(matchWatchPaths(['README.md'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['package.json'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['config/nginx.conf'], $watch_paths))->toBeEmpty(); +}); + +it('handles negation pattern with non matching file', function () { + // Test case: file that does NOT match the exclusion pattern should trigger + $changed_files = ['docker-compose/index.ts']; + $watch_paths = ['!docker-compose-test/**']; + + // Since the file docker-compose/index.ts does NOT match the exclusion pattern docker-compose-test/** + // it should trigger the deployment (file is included by default when only exclusion patterns exist) + // This means: "deploy everything EXCEPT files in docker-compose-test/**" + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->not->toBeEmpty(); + expect($matches)->toEqual(['docker-compose/index.ts']); + + // Test the opposite: file that DOES match the exclusion pattern should NOT trigger + $changed_files = ['docker-compose-test/index.ts']; + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->toBeEmpty(); + + // Test with deeper path + $changed_files = ['docker-compose-test/sub/dir/file.ts']; + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('handles mixed inclusion and exclusion patterns', function () { + // Include all JS files but exclude test directories + $watch_paths = ['**/*.js', '!**/*test*/**']; + + // Should match: JS files not in test directories + expect(matchWatchPaths(['src/index.js'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['components/Button.js'], $watch_paths))->not->toBeEmpty(); + + // Should NOT match: JS files in test directories + expect(matchWatchPaths(['test/unit/app.js'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['src/test-utils/helper.js'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['docker-compose-test/index.js'], $watch_paths))->toBeEmpty(); + + // Should NOT match: non-JS files + expect(matchWatchPaths(['README.md'], $watch_paths))->toBeEmpty(); +}); + +it('handles multiple negation patterns', function () { + // Exclude multiple directories + $watch_paths = ['!tests/**', '!docs/**', '!*.md']; + + // Should match: files not in excluded patterns + expect(matchWatchPaths(['src/index.js'], $watch_paths))->not->toBeEmpty(); + expect(matchWatchPaths(['docker-compose.yml'], $watch_paths))->not->toBeEmpty(); + + // Should NOT match: files in excluded patterns + expect(matchWatchPaths(['tests/unit/test.js'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['docs/api.html'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['README.md'], $watch_paths))->toBeEmpty(); + expect(matchWatchPaths(['CHANGELOG.md'], $watch_paths))->toBeEmpty(); +}); + +it('demonstrates current broken behavior with negation patterns', function () { + // This test demonstrates the CURRENT broken behavior + // where negation patterns are treated as literal strings + $changed_files = ['docker-compose/index.ts']; + $watch_paths = ['!docker-compose-test/**']; + + // With the current broken implementation, this returns empty + // because it tries to match files starting with literal "!" + $matches = matchWatchPathsCurrentBehavior($changed_files, $watch_paths); + expect($matches)->toBeEmpty(); // This is why your webhook doesn't trigger! + + // Even if the file had ! in the path, fnmatch would treat ! as a literal character + // not as a negation operator, so it still wouldn't match the pattern correctly + $changed_files = ['test/file.ts']; + $matches = matchWatchPathsCurrentBehavior($changed_files, $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('handles order based matching with conflicting patterns', function () { + // Test case 1: Exclude then include - last pattern (include) should win + $changed_files = ['docker-compose/index.ts']; + $watch_paths = ['!docker-compose/**', 'docker-compose/**']; + + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->not->toBeEmpty(); + expect($matches)->toEqual(['docker-compose/index.ts']); + + // Test case 2: Include then exclude - last pattern (exclude) should win + $watch_paths = ['docker-compose/**', '!docker-compose/**']; + + $matches = matchWatchPaths($changed_files, $watch_paths); + expect($matches)->toBeEmpty(); +}); + +it('handles order based matching with multiple overlapping patterns', function () { + $changed_files = ['src/test/unit.js', 'src/components/Button.js', 'test/integration.js']; + + // Include all JS, then exclude test dirs, then re-include specific test file + $watch_paths = [ + '**/*.js', // Include all JS files + '!**/test/**', // Exclude all test directories + 'src/test/unit.js', // Re-include this specific test file + ]; + + $matches = matchWatchPaths($changed_files, $watch_paths); + + // src/test/unit.js should be included (last specific pattern wins) + // src/components/Button.js should be included (only matches first pattern) + // test/integration.js should be excluded (matches exclude pattern, no override) + expect($matches)->toHaveCount(2); + expect($matches)->toContain('src/test/unit.js'); + expect($matches)->toContain('src/components/Button.js'); + expect($matches)->not->toContain('test/integration.js'); +}); + +it('handles order based matching with specific overrides', function () { + $changed_files = [ + 'docs/api.md', + 'docs/guide.md', + 'docs/internal/secret.md', + 'src/index.js', + ]; + + // Exclude all docs, then include specific docs subdirectory + $watch_paths = [ + '!docs/**', // Exclude all docs + 'docs/internal/**', // But include internal docs + 'src/**', // Include src files + ]; + + $matches = matchWatchPaths($changed_files, $watch_paths); + + // Only docs/internal/secret.md and src/index.js should be included + expect($matches)->toHaveCount(2); + expect($matches)->toContain('docs/internal/secret.md'); + expect($matches)->toContain('src/index.js'); + expect($matches)->not->toContain('docs/api.md'); + expect($matches)->not->toContain('docs/guide.md'); +}); + +it('preserves order precedence in pattern matching', function () { + $changed_files = ['app/config.json']; + + // Multiple conflicting patterns - last match should win + $watch_paths = [ + '**/*.json', // Include (matches) + '!app/**', // Exclude (matches) + 'app/*.json', // Include (matches) - THIS SHOULD WIN + ]; + + $matches = matchWatchPaths($changed_files, $watch_paths); + + // File should be included because last matching pattern is inclusive + expect($matches)->not->toBeEmpty(); + expect($matches)->toEqual(['app/config.json']); + + // Now reverse the last two patterns + $watch_paths = [ + '**/*.json', // Include (matches) + 'app/*.json', // Include (matches) + '!app/**', // Exclude (matches) - THIS SHOULD WIN + ]; + + $matches = matchWatchPaths($changed_files, $watch_paths); + + // File should be excluded because last matching pattern is exclusive + expect($matches)->toBeEmpty(); +});