char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsnapshot: embed shared main and worker startup in the CLI688e96e

main
16.0 KiB404 linesraw
1mod common;
2
3use std::fs;
4use std::path::Path;
5use std::process::{Child, Command, Stdio};
6use std::time::{Duration, Instant};
7
8const HELPERS: &str = r#"
9const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10async function nextWithin(it, ms, label) {
11    let timer;
12    const timeout = new Promise((_, reject) => {
13        timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), ms);
14    });
15    try { return await Promise.race([it.next(), timeout]); }
16    finally { clearTimeout(timer); }
17}
18async function until(it, predicate, label) {
19    const seen = [];
20    const deadline = Date.now() + 8000;
21    while (Date.now() < deadline) {
22        const step = await nextWithin(it, Math.max(1, deadline - Date.now()), label);
23        if (step.done) throw new Error(`Watcher ended waiting for ${label}: ${JSON.stringify(seen)}`);
24        seen.push(step.value);
25        if (predicate(step.value, seen)) return { event: step.value, seen };
26    }
27    throw new Error(`Timed out waiting for ${label}: ${JSON.stringify(seen)}`);
28}
29async function untilDone(it, label) {
30    const seen = [];
31    const deadline = Date.now() + 8000;
32    while (Date.now() < deadline) {
33        const step = await nextWithin(it, Math.max(1, deadline - Date.now()), label);
34        if (step.done) return seen;
35        seen.push(step.value);
36    }
37    throw new Error(`Timed out waiting for watcher to end: ${JSON.stringify(seen)}`);
38}
39function hasPath(event, suffix) {
40    return event.paths.some((path) => path.endsWith(suffix));
41}
42"#;
43
44fn spawn_with(source: &str, setup: impl FnOnce(&Path)) -> (tempfile::TempDir, Child) {
45    let dir = tempfile::tempdir().unwrap();
46    setup(dir.path());
47    fs::write(dir.path().join("main.js"), format!("{HELPERS}\n{source}")).unwrap();
48    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
49        .arg("run")
50        .current_dir(dir.path())
51        .arg("main.js")
52        .stdin(Stdio::null())
53        .stdout(Stdio::piped())
54        .stderr(Stdio::piped())
55        .spawn()
56        .unwrap();
57    (dir, child)
58}
59
60fn run_with(
61    source: &str,
62    flags: &[&str],
63    setup: impl FnOnce(&Path),
64) -> (tempfile::TempDir, std::process::Output) {
65    let dir = tempfile::tempdir().unwrap();
66    setup(dir.path());
67    fs::write(dir.path().join("main.js"), format!("{HELPERS}\n{source}")).unwrap();
68    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
69        .arg("run")
70        .current_dir(dir.path())
71        .args(flags)
72        .arg("main.js")
73        .stdin(Stdio::null())
74        .stdout(Stdio::piped())
75        .stderr(Stdio::piped())
76        .spawn()
77        .unwrap();
78    let output = common::wait(child);
79    assert!(
80        output.status.success(),
81        "{}",
82        String::from_utf8_lossy(&output.stderr)
83    );
84    (dir, output)
85}
86
87fn run(source: &str, flags: &[&str]) -> (tempfile::TempDir, std::process::Output) {
88    run_with(source, flags, |_| {})
89}
90
91#[test]
92fn reports_file_and_recursive_directory_events() {
93    let (_dir, output) = run(
94        r#"
95        import {FsWatcher, errors, mkdir, realPath, remove, rename, watchFs} from 'avril:std';
96        import {mkdirs, writeTextFile} from 'avril:fs';
97        try { new FsWatcher(); throw Error('Constructed watcher'); }
98        catch (error) { if (!(error instanceof TypeError)) throw error; }
99        await mkdirs('root/sub');
100        await writeTextFile('data.txt', 'one');
101        const root = await realPath('root');
102        const watcher = watchFs(new URL('./root', import.meta.url));
103        if (!(watcher instanceof FsWatcher)) throw Error('Watcher brand');
104        if (Object.prototype.toString.call(watcher) !== '[object FsWatcher]') throw Error('Watcher tag');
105        try { structuredClone(watcher); throw Error('Cloned watcher'); }
106        catch (error) { if (error.name !== 'DataCloneError') throw error; }
107        const it = watcher[Symbol.asyncIterator]();
108        await writeTextFile('root/hello.txt', 'x');
109        const created = await until(it, (event) => event.kind === 'create' && event.paths[0] === `${root}/hello.txt`, 'create');
110        if (!Object.isFrozen(created.event) || !Object.isFrozen(created.event.paths)) throw Error('Watcher event was mutable');
111        await writeTextFile('root/hello.txt', 'longer');
112        await until(it, (event) => event.kind === 'modify' && hasPath(event, '/hello.txt'), 'modify');
113        await mkdir('root/sub/new');
114        await until(it, (event) => hasPath(event, '/sub/new'), 'new directory');
115        await writeTextFile('root/sub/new/inner.txt', 'inner');
116        await until(it, (event) => hasPath(event, '/sub/new/inner.txt'), 'file in new directory');
117        await rename('root/hello.txt', 'root/renamed.txt');
118        await until(it, (event, seen) =>
119            event.kind === 'rename' && event.paths[0] === `${root}/hello.txt` && event.paths[1] === `${root}/renamed.txt` ||
120            hasPath(event, '/renamed.txt') && seen.some((item) => hasPath(item, '/hello.txt')), 'rename');
121        await remove('root/renamed.txt');
122        await until(it, (event) => event.kind === 'remove' && hasPath(event, '/renamed.txt'), 'remove');
123        watcher.close();
124        if (!(await it.next()).done) throw Error('Closed watcher produced an event');
125        try { watcher.close(); throw Error('Closed watcher twice'); }
126        catch (error) { if (!(error instanceof errors.BadResource)) throw error; }
127        watcher[Symbol.dispose]();
128
129        const fileWatcher = watchFs('data.txt');
130        const fileIt = fileWatcher[Symbol.asyncIterator]();
131        await writeTextFile('data.txt', 'two');
132        await until(fileIt, (event) => hasPath(event, '/data.txt'), 'file root event');
133        await writeTextFile('data.tmp', 'replacement');
134        await rename('data.tmp', 'data.txt');
135        await until(fileIt, (event) => hasPath(event, '/data.txt'), 'file root replacement');
136        await writeTextFile('data.txt', 'three');
137        await until(fileIt, (event) => hasPath(event, '/data.txt'), 'replaced file root event');
138        fileWatcher.close();
139        console.log('watch events');
140        "#,
141        &[],
142    );
143    assert_eq!(output.stdout, b"watch events\n");
144}
145
146#[test]
147fn honors_nonrecursive_mode_and_multiple_roots() {
148    let (_dir, output) = run(
149        r#"
150        import {watchFs} from 'avril:std';
151        import {mkdirs, writeTextFile} from 'avril:fs';
152        await mkdirs('a/sub');
153        await mkdirs('b');
154        const watcher = watchFs(['a', 'b'], {recursive: false});
155        const it = watcher[Symbol.asyncIterator]();
156        await writeTextFile('b/direct.txt', 'b');
157        await until(it, (event) => hasPath(event, '/b/direct.txt'), 'second root');
158        await writeTextFile('a/sub/hidden.txt', 'hidden');
159        await writeTextFile('a/direct.txt', 'a');
160        const {seen} = await until(it, (event) => hasPath(event, '/a/direct.txt'), 'first root direct child');
161        if (seen.some((event) => hasPath(event, '/hidden.txt'))) throw Error('Non-recursive watcher reported a descendant');
162        watcher[Symbol.dispose]();
163        if (!(await it.next()).done) throw Error('Disposed watcher produced an event');
164        console.log('watch scope');
165        "#,
166        &[],
167    );
168    assert_eq!(output.stdout, b"watch scope\n");
169}
170
171#[test]
172fn lifecycle_covers_close_abort_break_and_invalid_arguments() {
173    let (_dir, output) = run(
174        r#"
175        import {errors, watchFs} from 'avril:std';
176        import {mkdirs, writeTextFile} from 'avril:fs';
177        await mkdirs('root');
178        for (const call of [
179            () => watchFs(),
180            () => watchFs([]),
181            () => watchFs(42),
182            () => watchFs(['root', {}]),
183            () => watchFs('root', {recursive: 'yes'}),
184            () => watchFs('root', {signal: {}}),
185        ]) {
186            try { call(); throw Error('Accepted invalid watcher arguments'); }
187            catch (error) { if (!(error instanceof TypeError)) throw error; }
188        }
189        try { watchFs('missing'); throw Error('Watched a missing path'); }
190        catch (error) { if (!(error instanceof errors.NotFound)) throw error; }
191        const duplicate = watchFs(['root', 'root']);
192        duplicate.close();
193
194        const controller = new AbortController();
195        const aborted = watchFs('root', {signal: controller.signal});
196        const abortedIt = aborted[Symbol.asyncIterator]();
197        const pending = abortedIt.next();
198        controller.abort();
199        if (!(await pending).done) throw Error('Abort did not end iteration');
200        try { aborted.close(); throw Error('Aborted watcher closed twice'); }
201        catch (error) { if (!(error instanceof errors.BadResource)) throw error; }
202
203        const preAborted = new AbortController();
204        const reason = new Error('already stopped');
205        preAborted.abort(reason);
206        try { watchFs('root', {signal: preAborted.signal}); throw Error('Accepted aborted signal'); }
207        catch (error) { if (error !== reason) throw error; }
208
209        await writeTextFile('root/break.txt', 'x');
210        const breaking = watchFs('root');
211        let count = 0;
212        await writeTextFile('root/break-2.txt', 'y');
213        for await (const event of breaking) { count++; break; }
214        if (count !== 1) throw Error('Break did not receive one event');
215        try { breaking.close(); throw Error('Iterator return did not close watcher'); }
216        catch (error) { if (!(error instanceof errors.BadResource)) throw error; }
217        console.log('watch lifecycle');
218        "#,
219        &[],
220    );
221    assert_eq!(output.stdout, b"watch lifecycle\n");
222}
223
224#[test]
225fn watched_files_survive_recreation_and_directory_removal_ends_iteration() {
226    let (_dir, output) = run(
227        r#"
228        import {remove, watchFs} from 'avril:std';
229        import {mkdirs, writeTextFile} from 'avril:fs';
230        await mkdirs('root');
231        await writeTextFile('root/file.txt', 'x');
232        await writeTextFile('watched.txt', 'x');
233        const fileWatcher = watchFs('watched.txt');
234        const fileIt = fileWatcher[Symbol.asyncIterator]();
235        await remove('watched.txt');
236        await until(fileIt, (event) => event.kind === 'remove' && hasPath(event, '/watched.txt'), 'watched file removal');
237        await writeTextFile('watched.txt', 'recreated');
238        await until(fileIt, (event) => hasPath(event, '/watched.txt'), 'watched file recreation');
239        fileWatcher.close();
240
241        const watcher = watchFs('root');
242        const it = watcher[Symbol.asyncIterator]();
243        await remove('root', {recursive: true});
244        const seen = await untilDone(it, 'watched directory iteration end');
245        if (!seen.some((event) => hasPath(event, '/root'))) throw Error(`Root removal was not reported: ${JSON.stringify(seen)}`);
246        console.log('watch root removal');
247        "#,
248        &[],
249    );
250    assert_eq!(output.stdout, b"watch root removal\n");
251}
252
253#[test]
254fn capabilities_gate_creation_and_filter_delivery() {
255    let (_dir, output) = run_with(
256        r#"
257        import {errors, pledge, watchFs} from 'avril:std';
258        import {writeTextFile} from 'avril:fs';
259        try { watchFs('root', {recursive: true}); throw Error('Recursive watch crossed a denied subtree'); }
260        catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; }
261        const shallow = watchFs('root', {recursive: false});
262        shallow.close();
263        try { watchFs('root/missing'); throw Error('Watched missing path'); }
264        catch (error) { if (!(error instanceof errors.NotFound)) throw error; }
265
266        const watcher = watchFs('root/allowed');
267        const it = watcher[Symbol.asyncIterator]();
268        pledge({read: {deny: ['root/allowed/private']}});
269        await writeTextFile('root/allowed/private/hidden.txt', 'hidden');
270        await writeTextFile('root/allowed/visible.txt', 'visible');
271        const {seen} = await until(it, (event) => hasPath(event, '/visible.txt'), 'authorized event after pledge');
272        if (seen.some((event) => hasPath(event, '/hidden.txt') || hasPath(event, '/private/'))) {
273            throw Error(`Denied event leaked: ${JSON.stringify(seen)}`);
274        }
275        pledge({read: false});
276        await writeTextFile('root/allowed/after.txt', 'after');
277        try {
278            await nextWithin(it, 3000, 'revoked watcher');
279            throw Error('Revoked watcher produced an event');
280        } catch (error) {
281            if (!(error instanceof errors.PermissionDenied)) throw error;
282        }
283        console.log('watch caps');
284        "#,
285        &[
286            "-0",
287            "--allow-read=root/",
288            "--allow-write=root/",
289            "--deny-read=root/secret",
290        ],
291        |root| {
292            fs::create_dir_all(root.join("root/allowed/private")).unwrap();
293            fs::create_dir_all(root.join("root/secret")).unwrap();
294        },
295    );
296    assert_eq!(output.stdout, b"watch caps\n");
297}
298
299#[cfg(unix)]
300#[test]
301fn recursive_watch_does_not_follow_symlinked_directories() {
302    let (_dir, output) = run_with(
303        r#"
304        import {symlink, watchFs} from 'avril:std';
305        import {writeTextFile} from 'avril:fs';
306        await symlink('outside', 'root/link', {type: 'dir'});
307        const watcher = watchFs('root');
308        const it = watcher[Symbol.asyncIterator]();
309        await writeTextFile('outside/changed.txt', 'outside');
310        await writeTextFile('root/visible.txt', 'visible');
311        const {seen} = await until(it, (event) => hasPath(event, '/root/visible.txt'), 'visible event');
312        if (seen.some((event) => event.paths.some((path) => path.includes('/link/') || path.endsWith('/outside/changed.txt')))) {
313            throw Error(`Symlink target leaked: ${JSON.stringify(seen)}`);
314        }
315        watcher.close();
316        console.log('watch symlink');
317        "#,
318        &[],
319        |root| {
320            fs::create_dir_all(root.join("root")).unwrap();
321            fs::create_dir_all(root.join("outside")).unwrap();
322        },
323    );
324    assert_eq!(output.stdout, b"watch symlink\n");
325}
326
327#[test]
328fn unreferenced_watchers_do_not_keep_the_process_alive() {
329    let (_dir, output) = run_with(
330        r#"
331        import {watchFs} from 'avril:std';
332        const watcher = watchFs('root');
333        watcher.unref();
334        watcher.unref();
335        console.log('watch unref');
336        "#,
337        &[],
338        |root| {
339            fs::create_dir_all(root.join("root")).unwrap();
340        },
341    );
342    assert_eq!(output.stdout, b"watch unref\n");
343}
344
345#[test]
346fn referenced_watchers_keep_the_process_alive_until_closed() {
347    let (_dir, mut child) = spawn_with(
348        r#"
349        import {watchFs} from 'avril:std';
350        globalThis.watcher = watchFs('root');
351        console.log('watch ref');
352        "#,
353        |root| {
354            fs::create_dir_all(root.join("root")).unwrap();
355        },
356    );
357    let deadline = Instant::now() + Duration::from_millis(750);
358    while Instant::now() < deadline {
359        assert!(
360            child.try_wait().unwrap().is_none(),
361            "referenced watcher allowed the process to exit"
362        );
363        std::thread::sleep(Duration::from_millis(25));
364    }
365    child.kill().unwrap();
366    let output = common::wait(child);
367    assert_eq!(output.stdout, b"watch ref\n");
368}
369
370#[test]
371fn typescript_watch_types_are_usable() {
372    let dir = tempfile::tempdir().unwrap();
373    fs::create_dir_all(dir.path().join("root")).unwrap();
374    fs::write(
375        dir.path().join("main.ts"),
376        r#"
377        import {watchFs} from 'avril:std';
378        import type {FsEvent, FsWatcher, WatchOptions} from 'avril:std';
379        const options: WatchOptions = {recursive: false};
380        const watcher: FsWatcher = watchFs('root', options);
381        const events: AsyncIterable<FsEvent> = watcher;
382        void events;
383        watcher.close();
384        console.log('watch types');
385        "#,
386    )
387    .unwrap();
388    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
389        .arg("run")
390        .current_dir(dir.path())
391        .arg("main.ts")
392        .stdin(Stdio::null())
393        .stdout(Stdio::piped())
394        .stderr(Stdio::piped())
395        .spawn()
396        .unwrap();
397    let output = common::wait(child);
398    assert!(
399        output.status.success(),
400        "{}",
401        String::from_utf8_lossy(&output.stderr)
402    );
403    assert_eq!(output.stdout, b"watch types\n");
404}