Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:27:16 +08:00

368 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
});