|
| 1 | +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" |
| 2 | +import path from "path" |
| 3 | + |
| 4 | +// Under test |
| 5 | +import * as ripgrepMod from "../index" |
| 6 | +import { regexSearchFiles } from "../index" |
| 7 | +import { GitIgnoreController } from "../../../core/ignore/GitIgnoreController" |
| 8 | + |
| 9 | +// Mocks |
| 10 | +import * as fsPromises from "fs/promises" |
| 11 | +import type { Dirent } from "fs" |
| 12 | +import * as fileUtils from "../../../utils/fs" |
| 13 | + |
| 14 | +// Mock vscode (env + watchers used by controllers) |
| 15 | +vi.mock("vscode", () => { |
| 16 | + const mockDisposable = { dispose: vi.fn() } |
| 17 | + return { |
| 18 | + env: { appRoot: "/fake/vscode" }, |
| 19 | + workspace: { |
| 20 | + createFileSystemWatcher: vi.fn(() => ({ |
| 21 | + onDidCreate: vi.fn(() => mockDisposable), |
| 22 | + onDidChange: vi.fn(() => mockDisposable), |
| 23 | + onDidDelete: vi.fn(() => mockDisposable), |
| 24 | + dispose: vi.fn(), |
| 25 | + })), |
| 26 | + }, |
| 27 | + RelativePattern: vi.fn().mockImplementation((base: string, pattern: string) => ({ base, pattern })), |
| 28 | + } |
| 29 | +}) |
| 30 | + |
| 31 | +// Mock child_process.spawn to simulate ripgrep JSON line output |
| 32 | +vi.mock("child_process", () => { |
| 33 | + const { PassThrough } = require("stream") |
| 34 | + const { EventEmitter } = require("events") |
| 35 | + |
| 36 | + return { |
| 37 | + spawn: (_bin: string, _args: string[]) => { |
| 38 | + const proc = new EventEmitter() |
| 39 | + const stdout = new PassThrough() |
| 40 | + const stderr = new PassThrough() |
| 41 | + // Expose stdout/stderr streams |
| 42 | + ;(proc as any).stdout = stdout |
| 43 | + ;(proc as any).stderr = stderr |
| 44 | + ;(proc as any).kill = vi.fn(() => { |
| 45 | + stdout.end() |
| 46 | + stderr.end() |
| 47 | + }) |
| 48 | + |
| 49 | + // Defer writing until next tick to simulate async process output |
| 50 | + setImmediate(() => { |
| 51 | + const lines: string[] = (globalThis as any).__RG_LINES__ ?? [] |
| 52 | + for (const ln of lines) { |
| 53 | + stdout.write(ln + "\n") |
| 54 | + } |
| 55 | + stdout.end() |
| 56 | + }) |
| 57 | + |
| 58 | + return proc |
| 59 | + }, |
| 60 | + } |
| 61 | +}) |
| 62 | + |
| 63 | +// Ensure fs/promises and file utils are mockable from tests |
| 64 | +// Provide explicit mock factory so readdir/readFile are defined vi.fn() |
| 65 | +vi.mock("fs/promises", () => ({ |
| 66 | + readdir: vi.fn(), |
| 67 | + readFile: vi.fn(), |
| 68 | +})) |
| 69 | +vi.mock("../../../utils/fs") |
| 70 | +// Mock fs so BaseIgnoreController's realpathSync won't touch the real filesystem |
| 71 | +vi.mock("fs", () => ({ |
| 72 | + realpathSync: vi.fn((filePath: any) => filePath.toString()), |
| 73 | +})) |
| 74 | + |
| 75 | +describe("regexSearchFiles + GitIgnoreController integration (nested .gitignore filtering)", () => { |
| 76 | + const REPO = "/tmp/repo" // test workspace root |
| 77 | + let mockReaddir: Mock<typeof fsPromises.readdir> |
| 78 | + let mockReadFile: Mock<typeof fsPromises.readFile> |
| 79 | + let mockFileExists: Mock<typeof fileUtils.fileExistsAtPath> |
| 80 | + |
| 81 | + beforeEach(() => { |
| 82 | + vi.clearAllMocks() |
| 83 | + |
| 84 | + // Obtain mocked fs/promises fns from mock factory |
| 85 | + const anyFs = fsPromises as any |
| 86 | + mockReaddir = anyFs.readdir as unknown as Mock<typeof fsPromises.readdir> |
| 87 | + mockReadFile = anyFs.readFile as unknown as Mock<typeof fsPromises.readFile> |
| 88 | + |
| 89 | + mockFileExists = fileUtils.fileExistsAtPath as unknown as Mock<typeof fileUtils.fileExistsAtPath> |
| 90 | + |
| 91 | + // Provide a fake ripgrep path so getBinPath succeeds regardless of VSCode layout |
| 92 | + vi.spyOn(ripgrepMod, "getBinPath").mockResolvedValue("/fake/rg") |
| 93 | + |
| 94 | + // realpathSync handled by vi.mock("fs") factory above |
| 95 | + |
| 96 | + // Default: no files exist |
| 97 | + mockFileExists.mockResolvedValue(false) |
| 98 | + |
| 99 | + // Default dirents helper |
| 100 | + const dirent = (name: string, isDir: boolean): Dirent => |
| 101 | + ({ |
| 102 | + name, |
| 103 | + isDirectory: () => isDir, |
| 104 | + isFile: () => !isDir, |
| 105 | + isSymbolicLink: () => false, |
| 106 | + }) as unknown as Dirent |
| 107 | + // Default readdir: empty |
| 108 | + mockReaddir.mockImplementation(async (_p: any, _opts?: any) => { |
| 109 | + return [] as any |
| 110 | + }) |
| 111 | + |
| 112 | + // Default readFile: empty |
| 113 | + mockReadFile.mockResolvedValue("") |
| 114 | + }) |
| 115 | + |
| 116 | + it("excludes matches from files ignored by nested src/.gitignore patterns while keeping allowed files", async () => { |
| 117 | + // Arrange a nested .gitignore structure: |
| 118 | + // REPO/ |
| 119 | + // src/.gitignore => '*.tmp' (ignore), '!keep.tmp' (negation) |
| 120 | + // src/ignored.tmp (should be filtered) |
| 121 | + // src/keep.tmp (should be kept due to negation) |
| 122 | + // README.md (not under src, unaffected) |
| 123 | + // |
| 124 | + // GitIgnoreController recursively discovers src/.gitignore and adjusts patterns relative to REPO. |
| 125 | + |
| 126 | + // File existence for .gitignore files AND ripgrep binary resolution |
| 127 | + mockFileExists.mockImplementation(async (p: string) => { |
| 128 | + // Make getBinPath succeed by faking rg binary under VSCode appRoot |
| 129 | + const binName = process.platform.startsWith("win") ? "rg.exe" : "rg" |
| 130 | + const rgCandidate = path.join("/fake/vscode", "node_modules/@vscode/ripgrep/bin/", binName) |
| 131 | + if (p === rgCandidate) return true |
| 132 | + |
| 133 | + if (p === path.join(REPO, "src", ".gitignore")) return true |
| 134 | + // root .gitignore does not exist for this test |
| 135 | + if (p === path.join(REPO, ".gitignore")) return false |
| 136 | + return false |
| 137 | + }) |
| 138 | + |
| 139 | + // Directory tree: REPO has 'src' subdir |
| 140 | + const dirent = (name: string, isDir: boolean): Dirent => |
| 141 | + ({ |
| 142 | + name, |
| 143 | + isDirectory: () => isDir, |
| 144 | + isFile: () => !isDir, |
| 145 | + isSymbolicLink: () => false, |
| 146 | + }) as unknown as Dirent |
| 147 | + |
| 148 | + mockReaddir.mockImplementation(async (p: any, _opts?: any) => { |
| 149 | + if (p === REPO) { |
| 150 | + return [dirent("src", true)] as any |
| 151 | + } |
| 152 | + if (p === path.join(REPO, "src")) { |
| 153 | + // No further subdirectories required for this test |
| 154 | + return [] as any |
| 155 | + } |
| 156 | + return [] as any |
| 157 | + }) |
| 158 | + |
| 159 | + // src/.gitignore content |
| 160 | + mockReadFile.mockImplementation(async (p: any, _enc?: any) => { |
| 161 | + if (p === path.join(REPO, "src", ".gitignore")) { |
| 162 | + return "*.tmp\n!keep.tmp\n" |
| 163 | + } |
| 164 | + return "" |
| 165 | + }) |
| 166 | + |
| 167 | + // Prepare ripgrep JSON lines for three files: ignored.tmp, keep.tmp, README.md |
| 168 | + const rgLines = [ |
| 169 | + // src/ignored.tmp |
| 170 | + JSON.stringify({ type: "begin", data: { path: { text: "src/ignored.tmp" } } }), |
| 171 | + JSON.stringify({ |
| 172 | + type: "match", |
| 173 | + data: { line_number: 1, lines: { text: "foo" }, absolute_offset: 1 }, |
| 174 | + }), |
| 175 | + JSON.stringify({ type: "end", data: {} }), |
| 176 | + |
| 177 | + // src/keep.tmp |
| 178 | + JSON.stringify({ type: "begin", data: { path: { text: "src/keep.tmp" } } }), |
| 179 | + JSON.stringify({ |
| 180 | + type: "match", |
| 181 | + data: { line_number: 2, lines: { text: "foo" }, absolute_offset: 10 }, |
| 182 | + }), |
| 183 | + JSON.stringify({ type: "end", data: {} }), |
| 184 | + |
| 185 | + // README.md (outside src, unaffected) |
| 186 | + JSON.stringify({ type: "begin", data: { path: { text: "README.md" } } }), |
| 187 | + JSON.stringify({ |
| 188 | + type: "match", |
| 189 | + data: { line_number: 3, lines: { text: "foo" }, absolute_offset: 20 }, |
| 190 | + }), |
| 191 | + JSON.stringify({ type: "end", data: {} }), |
| 192 | + ] |
| 193 | + ;(globalThis as any).__RG_LINES__ = rgLines |
| 194 | + |
| 195 | + // Initialize controller with nested .gitignore |
| 196 | + const git = new GitIgnoreController(REPO) |
| 197 | + await git.initialize() |
| 198 | + // Sanity-check controller behavior before invoking ripgrep filter |
| 199 | + expect(git.hasGitignoreFiles()).toBe(true) |
| 200 | + expect(git.validateAccess("src/ignored.tmp")).toBe(false) |
| 201 | + expect(git.validateAccess("src/keep.tmp")).toBe(true) |
| 202 | + |
| 203 | + // Act |
| 204 | + const out = await regexSearchFiles(REPO, REPO, "foo", "*", undefined, git) |
| 205 | + |
| 206 | + // Assert: filtered summary and per-file sections |
| 207 | + // - src/ignored.tmp must be filtered out |
| 208 | + // - src/keep.tmp must be present (negation) |
| 209 | + // - README.md must be present |
| 210 | + expect(out).not.toContain("# src/ignored.tmp") |
| 211 | + expect(out).toContain("# src/keep.tmp") |
| 212 | + expect(out).toContain("# README.md") |
| 213 | + }) |
| 214 | +}) |
0 commit comments