import fs, { FSWatcher } from "node:fs"; import path from "path"; import { tempDirWithFiles, bunRun, bunRunAsScript } from "harness"; import { pathToFileURL } from "bun"; import { describe, expect, test } from "bun:test"; // Because macOS (and possibly other operating systems) can return a watcher // before it is actually watching, we need to repeat the operation to avoid // a race condition. function repeat(fn: any) { const interval = setInterval(fn, 20); return interval; } const encodingFileName = `新建文夹件.txt`; const testDir = tempDirWithFiles("watch", { "watch.txt": "hello", "relative.txt": "hello", "abort.txt": "hello", "url.txt": "hello", "close.txt": "hello", "close-close.txt": "hello", "sym-sync.txt": "hello", "sym.txt": "hello", [encodingFileName]: "hello", }); describe("fs.watch", () => { test("non-persistent watcher should not block the event loop", done => { try { // https://github.com/joyent/node/issues/2293 - non-persistent watcher should not block the event loop bunRun(path.join(import.meta.dir, "fixtures", "persistent.js")); done(); } catch (e: any) { done(e); } }); test("watcher should close and not block the event loop", done => { try { bunRun(path.join(import.meta.dir, "fixtures", "close.js")); done(); } catch (e: any) { done(e); } }); test("unref watcher should not block the event loop", done => { try { bunRun(path.join(import.meta.dir, "fixtures", "unref.js")); done(); } catch (e: any) { done(e); } }); test("should work with relative files", done => { try { bunRunAsScript(testDir, path.join(import.meta.dir, "fixtures", "relative.js")); done(); } catch (e: any) { done(e); } }); test("add file/folder to folder", done => { let count = 0; const root = path.join(testDir, "add-directory"); try { fs.mkdirSync(root); } catch {} let err: Error | undefined = undefined; const watcher = fs.watch(root, { signal: AbortSignal.timeout(3000) }); watcher.on("change", (event, filename) => { count++; try { expect(event).toBe("rename"); expect(["new-file.txt", "new-folder.txt"]).toContain(filename); if (count >= 2) { watcher.close(); } } catch (e: any) { err = e; watcher.close(); } }); watcher.on("error", e => (err = e)); watcher.on("close", () => { clearInterval(interval); done(err); }); const interval = repeat(() => { fs.writeFileSync(path.join(root, "new-file.txt"), "hello"); fs.mkdirSync(path.join(root, "new-folder.txt")); fs.rmdirSync(path.join(root, "new-folder.txt")); }); }); test("add file/folder to subfolder", done => { let count = 0; const root = path.join(testDir, "add-subdirectory"); try { fs.mkdirSync(root); } catch {} const subfolder = path.join(root, "subfolder"); fs.mkdirSync(subfolder); const watcher = fs.watch(root, { recursive: true, signal: AbortSignal.timeout(3000) }); let err: Error | undefined = undefined; watcher.on("change", (event, filename) => { const basename = path.basename(filename as string); if (basename === "subfolder") return; count++; try { expect(event).toBe("rename"); expect(["new-file.txt", "new-folder.txt"]).toContain(basename); if (count >= 2) { watcher.close(); } } catch (e: any) { err = e; watcher.close(); } }); watcher.on("error", e => (err = e)); watcher.on("close", () => { clearInterval(interval); done(err); }); const interval = repeat(() => { fs.writeFileSync(path.join(subfolder, "new-file.txt"), "hello"); fs.mkdirSync(path.join(subfolder, "new-folder.txt")); fs.rmdirSync(path.join(subfolder, "new-folder.txt")); }); }); test("should emit event when file is deleted", done => { const testsubdir = tempDirWithFiles("subdir", { "deleted.txt": "hello", }); const filepath = path.join(testsubdir, "deleted.txt"); let err: Error | undefined = undefined; const watcher = fs.watch(testsubdir, function (event, filename) { try { expect(event).toBe("rename"); expect(filename).toBe("deleted.txt"); } catch (e: any) { err = e; } finally { clearInterval(interval); watcher.close(); } }); watcher.once("close", () => { done(err); }); const interval = repeat(() => { fs.rmSync(filepath, { force: true }); const fd = fs.openSync(filepath, "w"); fs.closeSync(fd); }); }); // https://github.com/oven-sh/bun/issues/5442 test("should work with paths with trailing slashes", done => { const testsubdir = tempDirWithFiles("subdir", { "trailing.txt": "hello", }); const filepath = path.join(testsubdir, "trailing.txt"); let err: Error | undefined = undefined; const watcher = fs.watch(testsubdir + "/", function (event, filename) { try { expect(event).toBe("rename"); expect(filename).toBe("trailing.txt"); } catch (e: any) { err = e; } finally { clearInterval(interval); watcher.close(); } }); watcher.once("close", () => { done(err); }); const interval = repeat(() => { fs.rmSync(filepath, { force: true }); const fd = fs.openSync(filepath, "w"); fs.closeSync(fd); }); }); test("should emit 'change' event when file is modified", done => { const filepath = path.join(testDir, "watch.txt"); const watcher = fs.watch(filepath); let err: Error | undefined = undefined; watcher.on("change", function (event, filename) { try { expect(event).toBe("change"); expect(filename).toBe("watch.txt"); } catch (e: any) { err = e; } finally { clearInterval(interval); watcher.close(); } }); watcher.once("close", () => { done(err); }); const interval = repeat(() => { fs.writeFileSync(filepath, "world"); }); }, 10000); test("should error on invalid path", done => { try { fs.watch(path.join(testDir, "404.txt")); done(new Error("should not reach here")); } catch (err: any) { expect(err).toBeInstanceOf(Error); expect(err.code).toBe("ENOENT"); expect(err.syscall).toBe("watch"); done(); } }); const encodings = ["utf8", "buffer", "hex", "ascii", "base64", "utf16le", "ucs2", "latin1", "binary"] as const; test(`should work with encodings ${encodings.join(", ")}`, async () => { const watchers: FSWatcher[] = []; const filepath = path.join(testDir, encodingFileName); const promises: Promise[] = []; encodings.forEach(name => { const encoded_filename = name !== "buffer" ? Buffer.from(encodingFileName, "utf8").toString(name) : Buffer.from(encodingFileName); promises.push( new Promise((resolve, reject) => { watchers.push( fs.watch(filepath, { encoding: name }, (event, filename) => { try { expect(event).toBe("change"); if (name !== "buffer") { expect(filename).toBe(encoded_filename); } else { expect(filename).toBeInstanceOf(Buffer); expect((filename as any as Buffer)!.toString("utf8")).toBe(encodingFileName); } resolve(undefined); } catch (e: any) { reject(e); } }), ); }), ); }); const interval = repeat(() => { fs.writeFileSync(filepath, "world"); }); try { await Promise.all(promises); } finally { clearInterval(interval); watchers.forEach(watcher => watcher.close()); } }, 10000); test("should work with url", done => { const filepath = path.join(testDir, "url.txt"); try { const watcher = fs.watch(pathToFileURL(filepath)); let err: Error | undefined = undefined; watcher.on("change", function (event, filename) { try { expect(event).toBe("change"); expect(filename).toBe("url.txt"); } catch (e: any) { err = e; } finally { clearInterval(interval); watcher.close(); } }); watcher.once("close", () => { done(err); }); const interval = repeat(() => { fs.writeFileSync(filepath, "world"); }); } catch (e: any) { done(e); } }); test("calling close from error event should not throw", done => { const filepath = path.join(testDir, "close.txt"); try { const ac = new AbortController(); const watcher = fs.watch(pathToFileURL(filepath), { signal: ac.signal }); watcher.once("error", () => { try { watcher.close(); done(); } catch (e: any) { done("Should not error when calling close from error event"); } }); ac.abort(); } catch (e: any) { done(e); } }); test("calling close from close event should not throw", done => { const filepath = path.join(testDir, "close-close.txt"); try { const ac = new AbortController(); const watcher = fs.watch(pathToFileURL(filepath), { signal: ac.signal }); watcher.once("close", () => { try { watcher.close(); done(); } catch (e: any) { done("Should not error when calling close from close event"); } }); ac.abort(); } catch (e: any) { done(e); } }); test("Signal aborted after creating the watcher", async () => { const filepath = path.join(testDir, "abort.txt"); const ac = new AbortController(); const promise = new Promise((resolve, reject) => { const watcher = fs.watch(filepath, { signal: ac.signal }); watcher.once("error", err => (err.message === "The operation was aborted." ? resolve(undefined) : reject(err))); watcher.once("close", () => reject()); }); await Bun.sleep(10); ac.abort(); await promise; }); test("Signal aborted before creating the watcher", async () => { const filepath = path.join(testDir, "abort.txt"); const signal = AbortSignal.abort(); await new Promise((resolve, reject) => { const watcher = fs.watch(filepath, { signal }); watcher.once("error", err => (err.message === "The operation was aborted." ? resolve(undefined) : reject(err))); watcher.once("close", () => reject()); }); }); test("should work with symlink", async () => { const filepath = path.join(testDir, "sym-symlink2.txt"); await fs.promises.symlink(path.join(testDir, "sym-sync.txt"), filepath); const interval = repeat(() => { fs.writeFileSync(filepath, "hello"); }); const promise = new Promise((resolve, reject) => { let timeout: any = null; const watcher = fs.watch(filepath, event => { clearTimeout(timeout); clearInterval(interval); try { resolve(event); } catch (e: any) { reject(e); } finally { watcher.close(); } }); setTimeout(() => { clearInterval(interval); watcher?.close(); reject("timeout"); }, 3000); }); expect(promise).resolves.toBe("change"); }); test("should throw if no permission to watch the directory", async () => { const filepath = path.join(testDir, "permission-dir"); fs.mkdirSync(filepath, { recursive: true }); await fs.promises.chmod(filepath, 0o200); try { const watcher = fs.watch(filepath); watcher.close(); expect("unreacheable").toBe(false); } catch (err: any) { expect(err.message.indexOf("AccessDenied") !== -1).toBeTrue(); } }); test("should throw if no permission to watch the file", async () => { const filepath = path.join(testDir, "permission-file"); fs.writeFileSync(filepath, "hello.txt"); await fs.promises.chmod(filepath, 0o200); try { const watcher = fs.watch(filepath); watcher.close(); expect("unreacheable").toBe(false); } catch (err: any) { expect(err.message.indexOf("AccessDenied") !== -1).toBeTrue(); } }); }); describe("fs.promises.watch", () => { test("add file/folder to folder", async () => { let count = 0; const root = path.join(testDir, "add-promise-directory"); try { fs.mkdirSync(root); } catch {} let success = false; let err: Error | undefined = undefined; try { const ac = new AbortController(); const watcher = fs.promises.watch(root, { signal: ac.signal }); const interval = repeat(() => { fs.writeFileSync(path.join(root, "new-file.txt"), "hello"); fs.mkdirSync(path.join(root, "new-folder.txt")); fs.rmdirSync(path.join(root, "new-folder.txt")); }); for await (const event of watcher) { count++; try { expect(event.eventType).toBe("rename"); expect(["new-file.txt", "new-folder.txt"]).toContain(event.filename); if (count >= 2) { success = true; clearInterval(interval); ac.abort(); } } catch (e: any) { err = e; clearInterval(interval); ac.abort(); } } } catch (e: any) { if (!success) { throw err || e; } } }); test("add file/folder to subfolder", async () => { let count = 0; const root = path.join(testDir, "add-promise-subdirectory"); try { fs.mkdirSync(root); } catch {} const subfolder = path.join(root, "subfolder"); fs.mkdirSync(subfolder); let success = false; let err: Error | undefined = undefined; try { const ac = new AbortController(); const watcher = fs.promises.watch(root, { recursive: true, signal: ac.signal }); const interval = repeat(() => { fs.writeFileSync(path.join(subfolder, "new-file.txt"), "hello"); fs.mkdirSync(path.join(subfolder, "new-folder.txt")); fs.rmdirSync(path.join(subfolder, "new-folder.txt")); }); for await (const event of watcher) { const basename = path.basename(event.filename!); if (basename === "subfolder") continue; count++; try { expect(event.eventType).toBe("rename"); expect(["new-file.txt", "new-folder.txt"]).toContain(basename); if (count >= 2) { success = true; clearInterval(interval); ac.abort(); } } catch (e: any) { err = e; clearInterval(interval); ac.abort(); } } } catch (e: any) { if (!success) { throw err || e; } } }); test("Signal aborted after creating the watcher", async () => { const filepath = path.join(testDir, "abort.txt"); const ac = new AbortController(); const watcher = fs.promises.watch(filepath, { signal: ac.signal }); const promise = (async () => { try { for await (const _ of watcher); } catch (e: any) { expect(e.message).toBe("The operation was aborted."); } })(); await Bun.sleep(10); ac.abort(); await promise; }); test("Signal aborted before creating the watcher", async () => { const filepath = path.join(testDir, "abort.txt"); const signal = AbortSignal.abort(); const watcher = fs.promises.watch(filepath, { signal }); await (async () => { try { for await (const _ of watcher); } catch (e: any) { expect(e.message).toBe("The operation was aborted."); } })(); }); test("should work with symlink -> symlink -> dir", async () => { const filepath = path.join(testDir, "sym-symlink-indirect"); const dest = path.join(testDir, "sym-symlink-dest"); fs.rmSync(filepath, { recursive: true, force: true }); fs.rmSync(dest, { recursive: true, force: true }); fs.mkdirSync(dest, { recursive: true }); await fs.promises.symlink(dest, filepath); const indirect_sym = path.join(testDir, "sym-symlink-to-symlink-dir"); await fs.promises.symlink(filepath, indirect_sym); const watcher = fs.promises.watch(indirect_sym); const interval = setInterval(() => { fs.writeFileSync(path.join(indirect_sym, "hello.txt"), "hello"); }, 10); const promise = (async () => { try { for await (const event of watcher) { return event.eventType; } } catch { expect("unreacheable").toBe(false); } finally { clearInterval(interval); } })(); expect(promise).resolves.toBe("rename"); }); test("should work with symlink dir", async () => { const filepath = path.join(testDir, "sym-symlink-dir"); const dest = path.join(testDir, "sym-symlink-dest"); fs.rmSync(filepath, { recursive: true, force: true }); fs.rmSync(dest, { recursive: true, force: true }); fs.mkdirSync(dest, { recursive: true }); await fs.promises.symlink(dest, filepath); const watcher = fs.promises.watch(filepath); const interval = setInterval(() => { fs.writeFileSync(path.join(filepath, "hello.txt"), "hello"); }, 10); const promise = (async () => { try { for await (const event of watcher) { return event.eventType; } } catch { expect("unreacheable").toBe(false); } finally { clearInterval(interval); } })(); expect(promise).resolves.toBe("rename"); }); test("should work with symlink", async () => { const filepath = path.join(testDir, "sym-symlink.txt"); await fs.promises.symlink(path.join(testDir, "sym.txt"), filepath); const watcher = fs.promises.watch(filepath); const interval = repeat(() => { fs.writeFileSync(filepath, "hello"); }); const promise = (async () => { try { for await (const event of watcher) { return event.eventType; } } catch (e: any) { expect("unreacheable").toBe(false); } finally { clearInterval(interval); } })(); expect(promise).resolves.toBe("change"); }); }); describe("immediately closing", () => { test("works correctly with files", async () => { const filepath = path.join(testDir, "close.txt"); for (let i = 0; i < 100; i++) fs.watch(filepath, { persistent: true }).close(); for (let i = 0; i < 100; i++) fs.watch(filepath, { persistent: false }).close(); }); test("works correctly with directories", async () => { for (let i = 0; i < 100; i++) fs.watch(testDir, { persistent: true }).close(); for (let i = 0; i < 100; i++) fs.watch(testDir, { persistent: false }).close(); }); test("works correctly with recursive directories", async () => { for (let i = 0; i < 100; i++) fs.watch(testDir, { persistent: true, recursive: true }).close(); for (let i = 0; i < 100; i++) fs.watch(testDir, { persistent: false, recursive: false }).close(); }); });