d5f207424b
Python CI / Python bindings (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 1s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 0s
Rust CI / cargo clippy (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 0s
Rust CI / cargo fmt (push) Failing after 0s
Stylua / Check lua files using Stylua (push) Failing after 1s
Lua CI / lua-language-server type check (push) Failing after 12m29s
e2e Tests / e2e (alpine-musl) (push) Successful in 57m31s
e2e Tests / e2e (macos-latest) (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Rust CI / Test (windows-latest) (push) Has been cancelled
e2e Tests / e2e (windows-latest) (push) Has been cancelled
Nix CI / check (push) Has been cancelled
Python CI / Python bindings (macos-latest) (push) Has been cancelled
Python CI / Python bindings (windows-latest) (push) Has been cancelled
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
Rust CI / Fuzz Tests (windows-latest) (push) Has been cancelled
Rust CI / Test (macos-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (macos-latest) (push) Has been cancelled
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||
import { execSync } from "node:child_process";
|
||
import {
|
||
mkdirSync,
|
||
mkdtempSync,
|
||
realpathSync,
|
||
rmSync,
|
||
unlinkSync,
|
||
writeFileSync,
|
||
} from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import type { FileItem } from "../src/fff-api";
|
||
import { FileFinder } from "../src/index";
|
||
|
||
/**
|
||
* Integration test: full git lifecycle with a real repository.
|
||
*
|
||
* Creates a temporary git repo, initialises a FileFinder instance pointing at
|
||
* it, then walks through:
|
||
* 1. Initial scan – committed files should have status "clean"
|
||
* 2. Add a new untracked file – should appear as "untracked"
|
||
* 3. Stage the new file – should appear as "staged_new"
|
||
* 4. Commit – should become "clean"
|
||
* 5. Modify a tracked file – should become "modified"
|
||
* 6. Stage the modification – should become "staged_modified"
|
||
* 7. Commit again – back to "clean"
|
||
* 8. Delete a file – should disappear from the index
|
||
*/
|
||
|
||
const POLL_INTERVAL_MS = 100;
|
||
const WATCHER_TIMEOUT_MS = 5_000; // generous CI timeout; polls exit early on fast machines
|
||
|
||
function git(cwd: string, ...args: string[]) {
|
||
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||
execSync(`git ${escaped}`, {
|
||
cwd,
|
||
stdio: "pipe",
|
||
env: {
|
||
...process.env,
|
||
GIT_AUTHOR_NAME: "test",
|
||
GIT_AUTHOR_EMAIL: "test@test.com",
|
||
GIT_COMMITTER_NAME: "test",
|
||
GIT_COMMITTER_EMAIL: "test@test.com",
|
||
},
|
||
});
|
||
}
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise((r) => setTimeout(r, ms));
|
||
}
|
||
|
||
function findFile(finder: FileFinder, name: string): FileItem | undefined {
|
||
const result = finder.fileSearch(name, { pageSize: 200 });
|
||
if (!result.ok) throw new Error(`search failed: ${result.error}`);
|
||
return result.value.items.find((item) => item.fileName === name);
|
||
}
|
||
|
||
/** Poll until a file appears in the index, or the timeout is exceeded. */
|
||
async function waitForFile(
|
||
finder: FileFinder,
|
||
name: string,
|
||
): Promise<FileItem | undefined> {
|
||
const start = Date.now();
|
||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||
const file = findFile(finder, name);
|
||
if (file !== undefined) return file;
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
return findFile(finder, name);
|
||
}
|
||
|
||
/** Poll until a file has the expected git status, or the timeout is exceeded. */
|
||
async function waitForFileStatus(
|
||
finder: FileFinder,
|
||
name: string,
|
||
status: string,
|
||
): Promise<FileItem | undefined> {
|
||
const start = Date.now();
|
||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||
const file = findFile(finder, name);
|
||
if (file?.gitStatus === status) return file;
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
return findFile(finder, name);
|
||
}
|
||
|
||
/** Poll until a file is gone from the index, or the timeout is exceeded. */
|
||
async function waitForFileGone(finder: FileFinder, name: string): Promise<boolean> {
|
||
const start = Date.now();
|
||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||
if (findFile(finder, name) === undefined) return true;
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
return findFile(finder, name) === undefined;
|
||
}
|
||
|
||
/** Poll until the total file count reaches the expected value, or the timeout is exceeded. */
|
||
async function waitForFileCount(finder: FileFinder, count: number): Promise<number> {
|
||
const start = Date.now();
|
||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||
const result = finder.fileSearch("", { pageSize: 200 });
|
||
if (result.ok && result.value.totalFiles === count) return count;
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
const result = finder.fileSearch("", { pageSize: 200 });
|
||
return result.ok ? result.value.totalFiles : -1;
|
||
}
|
||
|
||
/** Poll grep until predicate on totalMatched is satisfied, or the timeout is exceeded. */
|
||
async function waitForGrep(
|
||
finder: FileFinder,
|
||
pattern: string,
|
||
options: { mode: "plain" | "regex" },
|
||
predicate: (totalMatched: number) => boolean,
|
||
) {
|
||
const start = Date.now();
|
||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||
const result = finder.grep(pattern, options);
|
||
if (result.ok && predicate(result.value.totalMatched)) return result;
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
return finder.grep(pattern, options);
|
||
}
|
||
|
||
describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => {
|
||
let tmpDir: string;
|
||
let finder: FileFinder;
|
||
|
||
beforeAll(async () => {
|
||
// Create temp directory and initialise a git repo with two committed files.
|
||
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
|
||
// that git2's resolved workdir paths match the file picker's base_path.
|
||
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
|
||
|
||
git(tmpDir, "init", "-b", "main");
|
||
// Need at least one commit for status to work properly
|
||
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
|
||
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
|
||
mkdirSync(join(tmpDir, "src"));
|
||
writeFileSync(join(tmpDir, "src", "main.rs"), 'fn main() { println?."hi"); }\n');
|
||
git(tmpDir, "add", "-A");
|
||
git(tmpDir, "commit", "-m", "initial commit");
|
||
|
||
// Create the FileFinder instance
|
||
const result = FileFinder.create({ basePath: tmpDir });
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) throw new Error(result.error);
|
||
finder = result.value;
|
||
|
||
// Wait for the initial scan to finish
|
||
const scanResult = finder.waitForScanBlocking(10_000);
|
||
expect(scanResult.ok).toBe(true);
|
||
|
||
// Poll getScanProgress until the watcher is ready so that
|
||
// filesystem events (file creates, deletes) are detected.
|
||
const start = Date.now();
|
||
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
|
||
const progress = finder.getScanProgress();
|
||
if (progress.ok && progress.value.isWatcherReady) break;
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
const progress = finder.getScanProgress();
|
||
expect(progress.ok).toBe(true);
|
||
if (progress.ok) {
|
||
expect(progress.value.isWatcherReady).toBe(true);
|
||
}
|
||
});
|
||
|
||
afterAll(() => {
|
||
finder?.destroy();
|
||
if (tmpDir) {
|
||
rmSync(tmpDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("initial scan indexes all committed files", () => {
|
||
const result = finder.fileSearch("", { pageSize: 200 });
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
|
||
const names = result.value.items.map((i) => i.relativePath).sort();
|
||
expect(names).toContain("hello.txt");
|
||
expect(names).toContain("readme.md");
|
||
expect(names).toContain("src/main.rs");
|
||
expect(result.value.totalFiles).toBe(3);
|
||
});
|
||
|
||
test("committed files have clean git status", async () => {
|
||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||
expect(hello).toBeDefined();
|
||
expect(hello?.gitStatus).toBe("clean");
|
||
|
||
const main = await waitForFileStatus(finder, "main.rs", "clean");
|
||
expect(main).toBeDefined();
|
||
expect(main?.gitStatus).toBe("clean");
|
||
});
|
||
|
||
test("new untracked file appears with 'untracked' status", async () => {
|
||
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
|
||
|
||
const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked");
|
||
expect(newFile).toBeDefined();
|
||
expect(newFile?.gitStatus).toBe("untracked");
|
||
|
||
// Total should now be 4
|
||
const total = await waitForFileCount(finder, 4);
|
||
expect(total).toBe(4);
|
||
});
|
||
|
||
test("staging a new file changes status to 'staged_new'", async () => {
|
||
git(tmpDir, "add", "new_file.ts");
|
||
|
||
const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new");
|
||
expect(newFile).toBeDefined();
|
||
expect(newFile?.gitStatus).toBe("staged_new");
|
||
});
|
||
|
||
test("committing makes the file 'clean'", async () => {
|
||
git(tmpDir, "commit", "-m", "add new_file");
|
||
|
||
const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
|
||
expect(newFile).toBeDefined();
|
||
expect(newFile?.gitStatus).toBe("clean");
|
||
});
|
||
|
||
test("modifying a tracked file changes status to 'modified'", async () => {
|
||
writeFileSync(join(tmpDir, "hello.txt"), "hello world\nupdated content\n");
|
||
|
||
const hello = await waitForFileStatus(finder, "hello.txt", "modified");
|
||
expect(hello).toBeDefined();
|
||
expect(hello?.gitStatus).toBe("modified");
|
||
});
|
||
|
||
test("staging a modification changes status to 'staged_modified'", async () => {
|
||
git(tmpDir, "add", "hello.txt");
|
||
|
||
const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified");
|
||
expect(hello).toBeDefined();
|
||
expect(hello?.gitStatus).toBe("staged_modified");
|
||
});
|
||
|
||
test("committing the modification returns to 'clean'", async () => {
|
||
git(tmpDir, "commit", "-m", "update hello");
|
||
|
||
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
|
||
expect(hello).toBeDefined();
|
||
expect(hello?.gitStatus).toBe("clean");
|
||
});
|
||
|
||
test("deleting a file removes it from the index", async () => {
|
||
unlinkSync(join(tmpDir, "new_file.ts"));
|
||
|
||
const gone = await waitForFileGone(finder, "new_file.ts");
|
||
expect(gone).toBe(true);
|
||
|
||
// Total should be back to 3
|
||
const total = await waitForFileCount(finder, 3);
|
||
expect(total).toBe(3);
|
||
});
|
||
|
||
test("adding a file in a subdirectory works", async () => {
|
||
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
|
||
|
||
const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
|
||
expect(utils).toBeDefined();
|
||
expect(utils?.relativePath).toBe("src/utils.rs");
|
||
expect(utils?.gitStatus).toBe("untracked");
|
||
});
|
||
|
||
test("live grep finds content in a newly added file", async () => {
|
||
writeFileSync(
|
||
join(tmpDir, "src", "searchtarget.rs"),
|
||
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
|
||
);
|
||
|
||
await waitForFile(finder, "searchtarget.rs");
|
||
|
||
const result = await waitForGrep(
|
||
finder,
|
||
"xylophone_waterfall_97",
|
||
{ mode: "plain" },
|
||
(n) => n > 0,
|
||
);
|
||
expect(result?.ok).toBe(true);
|
||
if (!result?.ok) return;
|
||
|
||
expect(result.value.totalMatched).toBeGreaterThan(0);
|
||
const match = result.value.items.find(
|
||
(m) => m.relativePath === "src/searchtarget.rs",
|
||
);
|
||
expect(match).toBeDefined();
|
||
expect(match!.lineContent).toContain("xylophone_waterfall_97");
|
||
});
|
||
|
||
test("live grep no longer finds content after file is deleted", async () => {
|
||
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
|
||
|
||
const result = await waitForGrep(
|
||
finder,
|
||
"xylophone_waterfall_97",
|
||
{ mode: "plain" },
|
||
(n) => n === 0,
|
||
);
|
||
expect(result?.ok).toBe(true);
|
||
if (!result?.ok) return;
|
||
|
||
expect(result.value.totalMatched).toBe(0);
|
||
expect(result.value.items.length).toBe(0);
|
||
});
|
||
|
||
test("file in a newly created directory is discoverable", async () => {
|
||
// Create a brand-new directory that didn't exist during the initial scan,
|
||
// then add a file inside it. The watcher must dynamically pick up the new
|
||
// directory and index the file.
|
||
mkdirSync(join(tmpDir, "lib"));
|
||
writeFileSync(
|
||
join(tmpDir, "lib", "helpers.ts"),
|
||
"export function add(a: number, b: number) { return a + b; }\n",
|
||
);
|
||
|
||
const helpers = await waitForFile(finder, "helpers.ts");
|
||
expect(helpers).toBeDefined();
|
||
expect(helpers?.relativePath).toBe("lib/helpers.ts");
|
||
});
|
||
|
||
test("files in gitignored directories are not indexed", async () => {
|
||
// Commit a .gitignore rule first so it's established repo state before
|
||
// the ignored directory is created. This tests the watch-level filtering
|
||
// (is_path_ignored in the debouncer callback), not a rescan triggered
|
||
// by a .gitignore change.
|
||
writeFileSync(join(tmpDir, ".gitignore"), "build_output/\n");
|
||
git(tmpDir, "add", ".gitignore");
|
||
git(tmpDir, "commit", "-m", "add gitignore");
|
||
|
||
// Wait for the watcher to settle after the commit.
|
||
await waitForFile(finder, ".gitignore");
|
||
|
||
// Now create the ignored directory and add a file inside it.
|
||
mkdirSync(join(tmpDir, "build_output"));
|
||
writeFileSync(join(tmpDir, "build_output", "artifact.bin"), "should not appear\n");
|
||
|
||
// Create a non-ignored file as a synchronisation barrier — once it's
|
||
// indexed, the watcher has processed the same batch of events.
|
||
writeFileSync(join(tmpDir, "canary.txt"), "visible\n");
|
||
const canary = await waitForFile(finder, "canary.txt");
|
||
expect(canary).toBeDefined();
|
||
|
||
// The ignored file must NOT appear in the index.
|
||
const artifact = findFile(finder, "artifact.bin");
|
||
expect(artifact).toBeUndefined();
|
||
});
|
||
|
||
test("full add-commit cycle for subdirectory file", async () => {
|
||
git(tmpDir, "add", "src/utils.rs");
|
||
|
||
let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
|
||
expect(utils).toBeDefined();
|
||
expect(utils?.gitStatus).toBe("staged_new");
|
||
|
||
git(tmpDir, "commit", "-m", "add utils");
|
||
|
||
utils = await waitForFileStatus(finder, "utils.rs", "clean");
|
||
expect(utils).toBeDefined();
|
||
expect(utils?.gitStatus).toBe("clean");
|
||
});
|
||
});
|