mod common; use std::fs; use std::path::Path; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; const HELPERS: &str = r#" const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function nextWithin(it, ms, label) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), ms); }); try { return await Promise.race([it.next(), timeout]); } finally { clearTimeout(timer); } } async function until(it, predicate, label) { const seen = []; const deadline = Date.now() + 8000; while (Date.now() < deadline) { const step = await nextWithin(it, Math.max(1, deadline - Date.now()), label); if (step.done) throw new Error(`Watcher ended waiting for ${label}: ${JSON.stringify(seen)}`); seen.push(step.value); if (predicate(step.value, seen)) return { event: step.value, seen }; } throw new Error(`Timed out waiting for ${label}: ${JSON.stringify(seen)}`); } async function untilDone(it, label) { const seen = []; const deadline = Date.now() + 8000; while (Date.now() < deadline) { const step = await nextWithin(it, Math.max(1, deadline - Date.now()), label); if (step.done) return seen; seen.push(step.value); } throw new Error(`Timed out waiting for watcher to end: ${JSON.stringify(seen)}`); } function hasPath(event, suffix) { return event.paths.some((path) => path.endsWith(suffix)); } "#; fn spawn_with(source: &str, setup: impl FnOnce(&Path)) -> (tempfile::TempDir, Child) { let dir = tempfile::tempdir().unwrap(); setup(dir.path()); fs::write(dir.path().join("main.js"), format!("{HELPERS}\n{source}")).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); (dir, child) } fn run_with( source: &str, flags: &[&str], setup: impl FnOnce(&Path), ) -> (tempfile::TempDir, std::process::Output) { let dir = tempfile::tempdir().unwrap(); setup(dir.path()); fs::write(dir.path().join("main.js"), format!("{HELPERS}\n{source}")).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(flags) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); (dir, output) } fn run(source: &str, flags: &[&str]) -> (tempfile::TempDir, std::process::Output) { run_with(source, flags, |_| {}) } #[test] fn reports_file_and_recursive_directory_events() { let (_dir, output) = run( r#" import {FsWatcher, errors, mkdir, realPath, remove, rename, watchFs} from 'avril:std'; import {mkdirs, writeTextFile} from 'avril:fs'; try { new FsWatcher(); throw Error('Constructed watcher'); } catch (error) { if (!(error instanceof TypeError)) throw error; } await mkdirs('root/sub'); await writeTextFile('data.txt', 'one'); const root = await realPath('root'); const watcher = watchFs(new URL('./root', import.meta.url)); if (!(watcher instanceof FsWatcher)) throw Error('Watcher brand'); if (Object.prototype.toString.call(watcher) !== '[object FsWatcher]') throw Error('Watcher tag'); try { structuredClone(watcher); throw Error('Cloned watcher'); } catch (error) { if (error.name !== 'DataCloneError') throw error; } const it = watcher[Symbol.asyncIterator](); await writeTextFile('root/hello.txt', 'x'); const created = await until(it, (event) => event.kind === 'create' && event.paths[0] === `${root}/hello.txt`, 'create'); if (!Object.isFrozen(created.event) || !Object.isFrozen(created.event.paths)) throw Error('Watcher event was mutable'); await writeTextFile('root/hello.txt', 'longer'); await until(it, (event) => event.kind === 'modify' && hasPath(event, '/hello.txt'), 'modify'); await mkdir('root/sub/new'); await until(it, (event) => hasPath(event, '/sub/new'), 'new directory'); await writeTextFile('root/sub/new/inner.txt', 'inner'); await until(it, (event) => hasPath(event, '/sub/new/inner.txt'), 'file in new directory'); await rename('root/hello.txt', 'root/renamed.txt'); await until(it, (event, seen) => event.kind === 'rename' && event.paths[0] === `${root}/hello.txt` && event.paths[1] === `${root}/renamed.txt` || hasPath(event, '/renamed.txt') && seen.some((item) => hasPath(item, '/hello.txt')), 'rename'); await remove('root/renamed.txt'); await until(it, (event) => event.kind === 'remove' && hasPath(event, '/renamed.txt'), 'remove'); watcher.close(); if (!(await it.next()).done) throw Error('Closed watcher produced an event'); try { watcher.close(); throw Error('Closed watcher twice'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } watcher[Symbol.dispose](); const fileWatcher = watchFs('data.txt'); const fileIt = fileWatcher[Symbol.asyncIterator](); await writeTextFile('data.txt', 'two'); await until(fileIt, (event) => hasPath(event, '/data.txt'), 'file root event'); await writeTextFile('data.tmp', 'replacement'); await rename('data.tmp', 'data.txt'); await until(fileIt, (event) => hasPath(event, '/data.txt'), 'file root replacement'); await writeTextFile('data.txt', 'three'); await until(fileIt, (event) => hasPath(event, '/data.txt'), 'replaced file root event'); fileWatcher.close(); console.log('watch events'); "#, &[], ); assert_eq!(output.stdout, b"watch events\n"); } #[test] fn honors_nonrecursive_mode_and_multiple_roots() { let (_dir, output) = run( r#" import {watchFs} from 'avril:std'; import {mkdirs, writeTextFile} from 'avril:fs'; await mkdirs('a/sub'); await mkdirs('b'); const watcher = watchFs(['a', 'b'], {recursive: false}); const it = watcher[Symbol.asyncIterator](); await writeTextFile('b/direct.txt', 'b'); await until(it, (event) => hasPath(event, '/b/direct.txt'), 'second root'); await writeTextFile('a/sub/hidden.txt', 'hidden'); await writeTextFile('a/direct.txt', 'a'); const {seen} = await until(it, (event) => hasPath(event, '/a/direct.txt'), 'first root direct child'); if (seen.some((event) => hasPath(event, '/hidden.txt'))) throw Error('Non-recursive watcher reported a descendant'); watcher[Symbol.dispose](); if (!(await it.next()).done) throw Error('Disposed watcher produced an event'); console.log('watch scope'); "#, &[], ); assert_eq!(output.stdout, b"watch scope\n"); } #[test] fn lifecycle_covers_close_abort_break_and_invalid_arguments() { let (_dir, output) = run( r#" import {errors, watchFs} from 'avril:std'; import {mkdirs, writeTextFile} from 'avril:fs'; await mkdirs('root'); for (const call of [ () => watchFs(), () => watchFs([]), () => watchFs(42), () => watchFs(['root', {}]), () => watchFs('root', {recursive: 'yes'}), () => watchFs('root', {signal: {}}), ]) { try { call(); throw Error('Accepted invalid watcher arguments'); } catch (error) { if (!(error instanceof TypeError)) throw error; } } try { watchFs('missing'); throw Error('Watched a missing path'); } catch (error) { if (!(error instanceof errors.NotFound)) throw error; } const duplicate = watchFs(['root', 'root']); duplicate.close(); const controller = new AbortController(); const aborted = watchFs('root', {signal: controller.signal}); const abortedIt = aborted[Symbol.asyncIterator](); const pending = abortedIt.next(); controller.abort(); if (!(await pending).done) throw Error('Abort did not end iteration'); try { aborted.close(); throw Error('Aborted watcher closed twice'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } const preAborted = new AbortController(); const reason = new Error('already stopped'); preAborted.abort(reason); try { watchFs('root', {signal: preAborted.signal}); throw Error('Accepted aborted signal'); } catch (error) { if (error !== reason) throw error; } await writeTextFile('root/break.txt', 'x'); const breaking = watchFs('root'); let count = 0; await writeTextFile('root/break-2.txt', 'y'); for await (const event of breaking) { count++; break; } if (count !== 1) throw Error('Break did not receive one event'); try { breaking.close(); throw Error('Iterator return did not close watcher'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } console.log('watch lifecycle'); "#, &[], ); assert_eq!(output.stdout, b"watch lifecycle\n"); } #[test] fn watched_files_survive_recreation_and_directory_removal_ends_iteration() { let (_dir, output) = run( r#" import {remove, watchFs} from 'avril:std'; import {mkdirs, writeTextFile} from 'avril:fs'; await mkdirs('root'); await writeTextFile('root/file.txt', 'x'); await writeTextFile('watched.txt', 'x'); const fileWatcher = watchFs('watched.txt'); const fileIt = fileWatcher[Symbol.asyncIterator](); await remove('watched.txt'); await until(fileIt, (event) => event.kind === 'remove' && hasPath(event, '/watched.txt'), 'watched file removal'); await writeTextFile('watched.txt', 'recreated'); await until(fileIt, (event) => hasPath(event, '/watched.txt'), 'watched file recreation'); fileWatcher.close(); const watcher = watchFs('root'); const it = watcher[Symbol.asyncIterator](); await remove('root', {recursive: true}); const seen = await untilDone(it, 'watched directory iteration end'); if (!seen.some((event) => hasPath(event, '/root'))) throw Error(`Root removal was not reported: ${JSON.stringify(seen)}`); console.log('watch root removal'); "#, &[], ); assert_eq!(output.stdout, b"watch root removal\n"); } #[test] fn capabilities_gate_creation_and_filter_delivery() { let (_dir, output) = run_with( r#" import {errors, pledge, watchFs} from 'avril:std'; import {writeTextFile} from 'avril:fs'; try { watchFs('root', {recursive: true}); throw Error('Recursive watch crossed a denied subtree'); } catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; } const shallow = watchFs('root', {recursive: false}); shallow.close(); try { watchFs('root/missing'); throw Error('Watched missing path'); } catch (error) { if (!(error instanceof errors.NotFound)) throw error; } const watcher = watchFs('root/allowed'); const it = watcher[Symbol.asyncIterator](); pledge({read: {deny: ['root/allowed/private']}}); await writeTextFile('root/allowed/private/hidden.txt', 'hidden'); await writeTextFile('root/allowed/visible.txt', 'visible'); const {seen} = await until(it, (event) => hasPath(event, '/visible.txt'), 'authorized event after pledge'); if (seen.some((event) => hasPath(event, '/hidden.txt') || hasPath(event, '/private/'))) { throw Error(`Denied event leaked: ${JSON.stringify(seen)}`); } pledge({read: false}); await writeTextFile('root/allowed/after.txt', 'after'); try { await nextWithin(it, 3000, 'revoked watcher'); throw Error('Revoked watcher produced an event'); } catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; } console.log('watch caps'); "#, &[ "-0", "--allow-read=root/", "--allow-write=root/", "--deny-read=root/secret", ], |root| { fs::create_dir_all(root.join("root/allowed/private")).unwrap(); fs::create_dir_all(root.join("root/secret")).unwrap(); }, ); assert_eq!(output.stdout, b"watch caps\n"); } #[cfg(unix)] #[test] fn recursive_watch_does_not_follow_symlinked_directories() { let (_dir, output) = run_with( r#" import {symlink, watchFs} from 'avril:std'; import {writeTextFile} from 'avril:fs'; await symlink('outside', 'root/link', {type: 'dir'}); const watcher = watchFs('root'); const it = watcher[Symbol.asyncIterator](); await writeTextFile('outside/changed.txt', 'outside'); await writeTextFile('root/visible.txt', 'visible'); const {seen} = await until(it, (event) => hasPath(event, '/root/visible.txt'), 'visible event'); if (seen.some((event) => event.paths.some((path) => path.includes('/link/') || path.endsWith('/outside/changed.txt')))) { throw Error(`Symlink target leaked: ${JSON.stringify(seen)}`); } watcher.close(); console.log('watch symlink'); "#, &[], |root| { fs::create_dir_all(root.join("root")).unwrap(); fs::create_dir_all(root.join("outside")).unwrap(); }, ); assert_eq!(output.stdout, b"watch symlink\n"); } #[test] fn unreferenced_watchers_do_not_keep_the_process_alive() { let (_dir, output) = run_with( r#" import {watchFs} from 'avril:std'; const watcher = watchFs('root'); watcher.unref(); watcher.unref(); console.log('watch unref'); "#, &[], |root| { fs::create_dir_all(root.join("root")).unwrap(); }, ); assert_eq!(output.stdout, b"watch unref\n"); } #[test] fn referenced_watchers_keep_the_process_alive_until_closed() { let (_dir, mut child) = spawn_with( r#" import {watchFs} from 'avril:std'; globalThis.watcher = watchFs('root'); console.log('watch ref'); "#, |root| { fs::create_dir_all(root.join("root")).unwrap(); }, ); let deadline = Instant::now() + Duration::from_millis(750); while Instant::now() < deadline { assert!( child.try_wait().unwrap().is_none(), "referenced watcher allowed the process to exit" ); std::thread::sleep(Duration::from_millis(25)); } child.kill().unwrap(); let output = common::wait(child); assert_eq!(output.stdout, b"watch ref\n"); } #[test] fn typescript_watch_types_are_usable() { let dir = tempfile::tempdir().unwrap(); fs::create_dir_all(dir.path().join("root")).unwrap(); fs::write( dir.path().join("main.ts"), r#" import {watchFs} from 'avril:std'; import type {FsEvent, FsWatcher, WatchOptions} from 'avril:std'; const options: WatchOptions = {recursive: false}; const watcher: FsWatcher = watchFs('root', options); const events: AsyncIterable = watcher; void events; watcher.close(); console.log('watch types'); "#, ) .unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.ts") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"watch types\n"); }