char/avril

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

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

Charlotte Somgenerate test typedefs from typescript impleae10ac

main
25.1 KiB769 linesraw
1mod common;
2
3use std::process::{Command, Output, Stdio};
4
5struct Suite {
6    dir: tempfile::TempDir,
7}
8
9impl Suite {
10    fn new() -> Self {
11        Self {
12            dir: tempfile::tempdir().unwrap(),
13        }
14    }
15
16    fn file(&self, name: &str, source: &str) -> String {
17        let path = self.dir.path().join(name);
18        if let Some(parent) = path.parent() {
19            std::fs::create_dir_all(parent).unwrap();
20        }
21        std::fs::write(&path, source).unwrap();
22        path.to_str().unwrap().to_owned()
23    }
24
25    fn test(&self, args: &[&str]) -> Output {
26        Command::new(env!("CARGO_BIN_EXE_avril"))
27            .arg("test")
28            .args(args)
29            .current_dir(self.dir.path())
30            .stdin(Stdio::null())
31            .stdout(Stdio::piped())
32            .stderr(Stdio::piped())
33            .spawn()
34            .map(common::wait)
35            .unwrap()
36    }
37
38    fn stdout(&self, args: &[&str]) -> String {
39        let output = self.test(args);
40        assert!(
41            output.status.success(),
42            "{args:?}: {}",
43            String::from_utf8_lossy(&output.stderr)
44        );
45        String::from_utf8(output.stdout).unwrap()
46    }
47
48    fn failure(&self, args: &[&str]) -> (String, String) {
49        let output = self.test(args);
50        assert!(
51            !output.status.success(),
52            "{args:?} unexpectedly passed: {}",
53            String::from_utf8_lossy(&output.stdout)
54        );
55        (
56            String::from_utf8(output.stdout).unwrap(),
57            String::from_utf8(output.stderr).unwrap(),
58        )
59    }
60}
61
62#[test]
63fn test_api_is_in_avril_test_not_avril_std() {
64    let output = common::run(
65        r#"
66        import { test } from 'avril:test';
67        const testModule = await import('avril:test');
68        if (testModule.test !== test) throw new Error('Wrong test binding');
69        if (Object.keys(testModule).join(',') !== 'test') throw new Error('Leaked test internals');
70        for (const [fn, name] of [[test, 'test'], [test.only, 'only'], [test.ignore, 'ignore']]) {
71            if (fn.name !== name || fn.length !== 2) throw new Error('Changed callable shape');
72        }
73        for (const name of ['only', 'ignore']) {
74            const descriptor = Object.getOwnPropertyDescriptor(test, name);
75            if (!descriptor.writable || !descriptor.enumerable || !descriptor.configurable) {
76                throw new Error('Changed test property descriptor');
77            }
78        }
79        const std = await import('avril:std');
80        if ('test' in std) throw new Error('avril:std still exports test');
81        test('inert under run', () => { throw new Error('must not run'); });
82        console.log('moved');
83    "#,
84        &[],
85    );
86    assert_eq!(output.stdout, b"moved\n");
87}
88
89#[test]
90fn runner_captures_date_clock_before_guest_evaluation() {
91    let suite = Suite::new();
92    suite.file(
93        "poison_clock_test.js",
94        r#"
95        import { test } from "avril:test";
96        const originalNow = Date.now;
97        Date.now = () => { throw new Error("runner captured the poisoned clock"); };
98        test("restore the clock", () => { Date.now = originalNow; });
99    "#,
100    );
101    let out = suite.stdout(&["poison_clock_test.js"]);
102    assert!(out.contains("ok - restore the clock"), "{out}");
103}
104
105#[test]
106fn registration_uses_captured_array_methods() {
107    let suite = Suite::new();
108    suite.file(
109        "captured_test.ts",
110        r#"
111        import { test } from "avril:test";
112        const push = Array.prototype.push;
113        Array.prototype.push = () => { throw new Error("poisoned push"); };
114        try {
115            test("filtered", () => { throw new Error("must not run"); });
116            test.only("focused", async (t) => {
117                if (!await t.step("child", () => {})) throw new Error("step failed");
118            });
119            test.only({ name: "ignored", ignore: true });
120        } finally {
121            Array.prototype.push = push;
122        }
123    "#,
124    );
125    let stdout = suite.stdout(&[]);
126    assert!(stdout.contains("ok - focused"), "{stdout}");
127    assert!(stdout.contains("ok - child"), "{stdout}");
128    assert!(
129        stdout.contains("1 passed; 0 failed; 1 ignored; 1 filtered"),
130        "{stdout}"
131    );
132}
133
134#[test]
135fn passing_suite_reports_each_test_and_a_summary() {
136    let suite = Suite::new();
137    suite.file(
138        "math_test.ts",
139        r#"
140        import { test } from 'avril:test';
141        test('adds', () => { if (1 + 1 !== 2) throw new Error('math broke'); });
142        test('async works', async () => {
143            await new Promise((resolve) => setTimeout(resolve, 5));
144        });
145    "#,
146    );
147    let out = suite.stdout(&["math_test.ts"]);
148    assert!(out.contains("Check ./math_test.ts"), "{out}");
149    assert!(out.contains("ok - adds"), "{out}");
150    assert!(out.contains("ok - async works"), "{out}");
151    assert!(
152        out.contains("ok - ./math_test.ts (2 passed, 0 failed, 0 ignored, 0 filtered)"),
153        "{out}"
154    );
155    assert!(
156        out.contains("test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered; 1 file."),
157        "{out}"
158    );
159}
160
161#[test]
162fn durations_reflect_wall_time() {
163    let suite = Suite::new();
164    suite.file(
165        "slow_test.js",
166        r#"
167        import { test } from 'avril:test';
168        test('slow', async () => {
169            await new Promise((resolve) => setTimeout(resolve, 200));
170        });
171        test({ name: 'no deadline', timeout: Infinity, fn() {} });
172    "#,
173    );
174    let out = suite.stdout(&["slow_test.js"]);
175    let line = out
176        .lines()
177        .find(|line| line.starts_with("ok - slow ("))
178        .unwrap_or("");
179    let elapsed: u64 = line
180        .strip_prefix("ok - slow (")
181        .and_then(|rest| rest.strip_suffix("ms)"))
182        .and_then(|digits| digits.parse().ok())
183        .unwrap_or(0);
184    assert!(elapsed >= 150, "{out}");
185    assert!(out.contains("ok - no deadline ("), "{out}");
186}
187
188#[test]
189fn spinning_tests_are_terminated_and_fail_their_file() {
190    let suite = Suite::new();
191    suite.file(
192        "spin_test.js",
193        r#"
194        import { test } from 'avril:test';
195        test({ name: 'spins', timeout: 50, fn() { for (;;) {} } });
196        test('never runs', () => {});
197    "#,
198    );
199    suite.file(
200        "after_test.js",
201        r#"
202        import { test } from 'avril:test';
203        test('unaffected', () => {});
204    "#,
205    );
206    let (out, _) = suite.failure(&["spin_test.js", "after_test.js"]);
207    assert!(out.contains("FAILED - spins (50ms)"), "{out}");
208    assert!(out.contains("Test \"spins\" timed out after 50ms"), "{out}");
209    assert!(!out.contains("never runs"), "{out}");
210    assert!(out.contains("ok - unaffected ("), "{out}");
211    assert!(
212        out.contains("test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 filtered; 2 files."),
213        "{out}"
214    );
215}
216
217#[test]
218fn timeouts_past_the_timer_range_are_rejected() {
219    let suite = Suite::new();
220    suite.file(
221        "huge_test.js",
222        r#"
223        import { test } from 'avril:test';
224        test({ name: 'huge', timeout: 3000000000, fn() {} });
225    "#,
226    );
227    let (out, _) = suite.failure(&["huge_test.js"]);
228    assert!(out.contains("at most 2147483647ms"), "{out}");
229}
230
231#[test]
232fn steps_resolve_false_and_fail_parents_without_throwing() {
233    let suite = Suite::new();
234    suite.file(
235        "semantics_test.js",
236        r#"
237        import { test } from 'avril:test';
238        test('concurrent', async (t) => {
239            const results = await Promise.all([
240                t.step('victim', () => {}),
241                t.step('latecomer', () => {}),
242            ]);
243            if (JSON.stringify(results) !== '[true,false]') throw new Error('bad results');
244        });
245        test('unawaited', (t) => {
246            t.step('late failure', async () => {
247                await new Promise((resolve) => setTimeout(resolve, 10));
248                throw new Error('late boom');
249            });
250        });
251        test('ignored then real', async (t) => {
252            // An ignored step must release the context for the next step.
253            if ((await t.step({ name: 'skipped', ignore: true })) !== false) {
254                throw new Error('bad ignore result');
255            }
256            if ((await t.step('real', () => {})) !== true) throw new Error('real step blocked');
257        });
258    "#,
259    );
260    let (out, _) = suite.failure(&["semantics_test.js"]);
261    assert!(out.contains("  ok - victim ("), "{out}");
262    assert!(out.contains("  FAILED - latecomer ("), "{out}");
263    assert!(out.contains("while another step was running"), "{out}");
264    assert!(out.contains("FAILED - concurrent ("), "{out}");
265    assert!(out.contains("due to 1 failed step"), "{out}");
266    assert!(out.contains("  FAILED - late failure ("), "{out}");
267    assert!(out.contains("late boom"), "{out}");
268    assert!(out.contains("FAILED - unawaited ("), "{out}");
269}
270
271#[test]
272fn stale_contexts_and_late_registrations_fail_loudly() {
273    let suite = Suite::new();
274    suite.file(
275        "stale_test.js",
276        r#"
277        import { test } from 'avril:test';
278        let stolen;
279        test('first', (t) => { stolen = t; });
280        test('second', async () => {
281            try {
282                await stolen.step('late', () => {});
283            } catch (error) {
284                if (!(error instanceof TypeError)) throw error;
285                if (!error.message.includes('called after "first" settled')) throw error;
286                return;
287            }
288            throw new Error('stale step was allowed');
289        });
290        test.ignore('nameless');
291    "#,
292    );
293    let out = suite.stdout(&["stale_test.js"]);
294    assert!(out.contains("ignored - nameless"), "{out}");
295    suite.file(
296        "late_test.js",
297        r#"
298        import { test } from 'avril:test';
299        test('first', () => { test('late', () => {}); });
300    "#,
301    );
302    let (out, _) = suite.failure(&["late_test.js"]);
303    assert!(
304        out.contains("must be called while the test file evaluates"),
305        "{out}"
306    );
307}
308
309#[test]
310fn unhandled_rejections_fail_the_file_with_detail() {
311    let suite = Suite::new();
312    suite.file(
313        "stray_test.js",
314        r#"
315        import { test } from 'avril:test';
316        test('ok', () => {});
317        test('boom', () => { Promise.reject(new Error('lost rejection')); });
318    "#,
319    );
320    let (out, _) = suite.failure(&["stray_test.js"]);
321    assert!(out.contains("ok - ok ("), "{out}");
322    assert!(
323        out.contains("Unhandled rejections during the test run:"),
324        "{out}"
325    );
326    assert!(out.contains("lost rejection"), "{out}");
327}
328
329#[test]
330fn step_option_errors_name_steps_and_missing_functions() {
331    let suite = Suite::new();
332    suite.file(
333        "badopts_test.js",
334        r#"
335        import { test } from 'avril:test';
336        test('options', async (t) => {
337            try {
338                await t.step({ name: 's', fn() {}, bogus: true });
339            } catch (error) {
340                if (error.message !== 'Unknown step option: bogus') throw error;
341            }
342            try {
343                await t.step({ name: 's', fn() {}, ignore: 1 });
344            } catch (error) {
345                if (error.message !== 'Step option ignore must be a boolean') throw error;
346                return;
347            }
348            throw new Error('bad step options were allowed');
349        });
350        test('missing', async (t) => {
351            try {
352                await t.step('nofn');
353            } catch (error) {
354                if (error.message !== 'Step "nofn" requires a function') throw error;
355                return;
356            }
357            throw new Error('missing step function was allowed');
358        });
359        test({ name: 'sync only', timeout: 0, fn() {} });
360    "#,
361    );
362    let out = suite.stdout(&["badopts_test.js"]);
363    assert!(out.contains("3 passed"), "{out}");
364}
365
366#[test]
367fn discovery_skips_dot_files_and_walks_symlinked_dirs() {
368    let suite = Suite::new();
369    suite.file(
370        "visible_test.js",
371        "import { test } from 'avril:test'; test('seen', () => {});",
372    );
373    suite.file(
374        ".hidden_test.js",
375        "import { test } from 'avril:test'; test('unseen', () => { throw new Error('ran'); });",
376    );
377    suite.file(
378        "real/inner_test.js",
379        "import { test } from 'avril:test'; test('linked', () => {});",
380    );
381    #[cfg(unix)]
382    std::os::unix::fs::symlink(suite.dir.path().join("real"), suite.dir.path().join("link"))
383        .unwrap();
384    // Walking the directory runs the visible file but never the dot-file.
385    let out = suite.stdout(&["."]);
386    assert!(out.contains("ok - seen ("), "{out}");
387    assert!(!out.contains("unseen"), "{out}");
388    #[cfg(unix)]
389    {
390        // An explicit symlinked directory walks its target.
391        let out = suite.stdout(&["link"]);
392        assert!(out.contains("ok - linked ("), "{out}");
393    }
394}
395
396#[test]
397fn failing_suite_fails_with_stacks_and_a_summary() {
398    let suite = Suite::new();
399    suite.file(
400        "fail_test.js",
401        r#"
402        import { test } from 'avril:test';
403        test('passes', () => {});
404        test('rejects', async () => { await Promise.resolve(); throw new Error('boom'); });
405        test('sync throws', () => { throw new TypeError('bad'); });
406    "#,
407    );
408    let (out, err) = suite.failure(&["fail_test.js"]);
409    assert!(out.contains("ok - passes"), "{out}");
410    assert!(out.contains("FAILED - rejects"), "{out}");
411    assert!(out.contains("FAILED - sync throws"), "{out}");
412    assert!(out.contains("Error: boom"), "{out}");
413    assert!(out.contains("TypeError: bad"), "{out}");
414    assert!(out.contains("fail_test.js:"), "{out}");
415    assert!(!out.contains("ext:test/main.js"), "{out}");
416    assert!(!out.contains("ext:test/runner.js"), "{out}");
417    assert!(
418        out.contains("FAILED - ./fail_test.js (1 passed, 2 failed, 0 ignored, 0 filtered)"),
419        "{out}"
420    );
421    assert!(
422        out.contains("test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 filtered; 1 file."),
423        "{out}"
424    );
425    assert!(err.contains("test failed"), "{err}");
426}
427
428#[test]
429fn filter_selects_by_substring_and_counts_the_rest() {
430    let suite = Suite::new();
431    suite.file(
432        "filter_test.js",
433        r#"
434        import { test } from 'avril:test';
435        test('apple', () => {});
436        test('banana', () => {});
437        test('crabapple', () => {});
438    "#,
439    );
440    let out = suite.stdout(&["--filter=apple", "filter_test.js"]);
441    assert!(out.contains("ok - apple"), "{out}");
442    assert!(out.contains("ok - crabapple"), "{out}");
443    assert!(!out.contains("ok - banana"), "{out}");
444    assert!(
445        out.contains("ok - ./filter_test.js (2 passed, 0 failed, 0 ignored, 1 filtered)"),
446        "{out}"
447    );
448}
449
450#[test]
451fn ignore_skips_and_only_focuses() {
452    let suite = Suite::new();
453    suite.file(
454        "focus_test.js",
455        r#"
456        import { test } from 'avril:test';
457        test('a', () => {});
458        test({ name: 'b', ignore: true, fn() { throw new Error('must not run'); } });
459        test.ignore('c');
460        test('d', () => {});
461    "#,
462    );
463    let out = suite.stdout(&["focus_test.js"]);
464    assert!(out.contains("ignored - b"), "{out}");
465    assert!(out.contains("ignored - c"), "{out}");
466    assert!(out.contains("ok - a"), "{out}");
467    assert!(out.contains("ok - d"), "{out}");
468    assert!(
469        out.contains("ok - ./focus_test.js (2 passed, 0 failed, 2 ignored, 0 filtered)"),
470        "{out}"
471    );
472    suite.file(
473        "only_test.js",
474        r#"
475        import { test } from 'avril:test';
476        test('a', () => { throw new Error('must not run'); });
477        test.only('b', () => {});
478        test({ name: 'c', only: true, fn() {} });
479    "#,
480    );
481    let out = suite.stdout(&["only_test.js"]);
482    assert!(out.contains("ok - b"), "{out}");
483    assert!(out.contains("ok - c"), "{out}");
484    assert!(!out.contains("ok - a"), "{out}");
485    assert!(
486        out.contains("ok - ./only_test.js (2 passed, 0 failed, 0 ignored, 1 filtered)"),
487        "{out}"
488    );
489}
490
491#[test]
492fn steps_nest_and_a_failing_step_fails_its_parent() {
493    let suite = Suite::new();
494    suite.file(
495        "steps_test.js",
496        r#"
497        import { test } from 'avril:test';
498        test('nested', async (t) => {
499            const first = await t.step('one', () => {});
500            const second = await t.step({ name: 'two', ignore: true, fn() {} });
501            if (first !== true || second !== false) throw new Error('bad step results');
502            await t.step('deep', async (t) => {
503                await t.step('deeper', () => {});
504            });
505        });
506        test('step failure', async (t) => {
507            await t.step('bad', () => { throw new Error('step boom'); });
508        });
509    "#,
510    );
511    let (out, _) = suite.failure(&["steps_test.js"]);
512    assert!(out.contains("ok - nested"), "{out}");
513    assert!(out.contains("  ok - one"), "{out}");
514    assert!(out.contains("  ignored - two"), "{out}");
515    assert!(out.contains("  ok - deep"), "{out}");
516    assert!(out.contains("    ok - deeper"), "{out}");
517    assert!(out.contains("FAILED - step failure"), "{out}");
518    assert!(out.contains("  FAILED - bad"), "{out}");
519    assert!(out.contains("step boom"), "{out}");
520}
521
522#[test]
523fn slow_tests_time_out() {
524    let suite = Suite::new();
525    suite.file(
526        "timeout_test.js",
527        r#"
528        import { test } from 'avril:test';
529        test({ name: 'hangs', timeout: 30, fn: () => new Promise(() => {}) });
530    "#,
531    );
532    let (out, _) = suite.failure(&["timeout_test.js"]);
533    assert!(out.contains("FAILED - hangs"), "{out}");
534    assert!(out.contains("timed out after 30ms"), "{out}");
535}
536
537#[test]
538fn files_are_isolated_but_run_in_sorted_order() {
539    let suite = Suite::new();
540    suite.file(
541        "a_first_test.js",
542        r#"
543        import { test } from 'avril:test';
544        test('sets global', () => { globalThis.__leak = 42; });
545    "#,
546    );
547    suite.file(
548        "b_second_test.js",
549        r#"
550        import { test } from 'avril:test';
551        test('sees no leak', () => {
552            if (globalThis.__leak !== undefined) throw new Error('state leaked across files');
553        });
554        test('shares within a file', () => { globalThis.__local = 1; });
555        test('reads within a file', () => {
556            if (globalThis.__local !== 1) throw new Error('no sharing within a file');
557        });
558    "#,
559    );
560    let out = suite.stdout(&["a_first_test.js", "b_second_test.js"]);
561    let first = out.find("Check ./a_first_test.js").unwrap();
562    let second = out.find("Check ./b_second_test.js").unwrap();
563    assert!(first < second, "{out}");
564    assert!(
565        out.contains("test result: ok. 4 passed; 0 failed; 0 ignored; 0 filtered; 2 files."),
566        "{out}"
567    );
568}
569
570#[test]
571fn permission_flags_apply_to_every_file() {
572    let suite = Suite::new();
573    let secret = suite.file("secret.txt", "s3cret");
574    let grant = format!("--allow-read={secret}");
575    suite.file(
576        "perm_test.js",
577        &format!(
578            r#"
579            import {{ test }} from 'avril:test';
580            import {{ readTextFile }} from 'avril:fs';
581            test('reads the fixture', async () => {{
582                if ((await readTextFile({secret:?})) !== 's3cret') throw new Error('bad fixture');
583            }});
584        "#
585        ),
586    );
587    let (out, _) = suite.failure(&["-0", "perm_test.js"]);
588    assert!(out.contains("FAILED - reads the fixture"), "{out}");
589    assert!(out.contains("PermissionDenied"), "{out}");
590    let out = suite.stdout(&["-0", &grant, "perm_test.js"]);
591    assert!(out.contains("ok - reads the fixture"), "{out}");
592}
593
594#[test]
595fn pledges_narrow_only_their_own_file() {
596    let suite = Suite::new();
597    suite.file("fixture.txt", "data");
598    suite.file("helper.js", "export function triple(n) { return n * 3; }");
599    suite.file(
600        "a_restrict_test.js",
601        r#"
602        import { pledge } from 'avril:std';
603        import { test } from 'avril:test';
604        pledge({ read: false, jsExec: false });
605        test('pure', () => { if (1 + 1 !== 2) throw new Error('bad'); });
606    "#,
607    );
608    suite.file(
609        "b_free_test.js",
610        r#"
611        import { test } from 'avril:test';
612        import { readTextFile } from 'avril:fs';
613        import { triple } from './helper.js';
614        test('reads and loads', async () => {
615            if (triple(14) !== 42) throw new Error('bad helper');
616            if ((await readTextFile('./fixture.txt')) !== 'data') throw new Error('bad read');
617        });
618    "#,
619    );
620    // The restricted file passes alone, the free file passes alone, and
621    // both pass together: narrowing must not leak across files.
622    suite.stdout(&["a_restrict_test.js"]);
623    suite.stdout(&["b_free_test.js"]);
624    let out = suite.stdout(&["a_restrict_test.js", "b_free_test.js"]);
625    assert!(
626        out.contains("test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered; 2 files."),
627        "{out}"
628    );
629}
630
631#[test]
632fn file_errors_carry_detail_and_fail_the_run() {
633    let suite = Suite::new();
634    suite.file("broken_test.js", "const = broken\n");
635    suite.file(
636        "nofn_test.js",
637        r#"
638        import { test } from 'avril:test';
639        test('missing function');
640    "#,
641    );
642    let (out, _) = suite.failure(&["broken_test.js", "nofn_test.js"]);
643    assert!(out.contains("FAILED - ./broken_test.js"), "{out}");
644    assert!(out.contains("SyntaxError"), "{out}");
645    assert!(out.contains("FAILED - ./nofn_test.js"), "{out}");
646    assert!(out.contains("requires a function"), "{out}");
647    assert!(out.contains("test result: FAILED."), "{out}");
648}
649
650#[test]
651fn shared_option_errors_keep_guest_frames_and_hide_test_internals() {
652    let suite = Suite::new();
653    let registration = suite.file(
654        "invalid_registration_test.js",
655        r#"
656        import { test } from "avril:test";
657        test({ name: "invalid", bogus: true });
658    "#,
659    );
660    let step = suite.file(
661        "invalid_step_test.js",
662        r#"
663        import { test } from "avril:test";
664        test("invalid step", (t) => t.step({ name: "invalid", bogus: true }));
665    "#,
666    );
667    let (out, _) = suite.failure(&[&registration, &step]);
668    assert!(out.contains("Unknown test option: bogus"), "{out}");
669    assert!(out.contains("Unknown step option: bogus"), "{out}");
670    assert!(out.contains("invalid_registration_test.js:"), "{out}");
671    assert!(out.contains("invalid_step_test.js:"), "{out}");
672    for frame in [
673        "ext:test/main.js",
674        "ext:test/runner.js",
675        "ext:test/shared.js",
676    ] {
677        assert!(!out.contains(frame), "{out}");
678    }
679}
680
681#[test]
682fn no_run_lists_tests_without_running_them() {
683    let suite = Suite::new();
684    suite.file(
685        "list_test.js",
686        r#"
687        import { test } from 'avril:test';
688        test('would run', () => { throw new Error('must not run'); });
689        test({ name: 'would skip', ignore: true, fn() {} });
690    "#,
691    );
692    let out = suite.stdout(&["--no-run", "list_test.js"]);
693    assert!(out.contains("test - would run"), "{out}");
694    assert!(out.contains("ignored - would skip"), "{out}");
695    assert!(!out.contains("must not run"), "{out}");
696    assert!(
697        out.contains("found - ./list_test.js (1 tests, not run)"),
698        "{out}"
699    );
700    assert!(
701        out.contains("test result: ok. found 1 tests in 1 file (not run)."),
702        "{out}"
703    );
704}
705
706#[test]
707fn discovery_walks_directories_and_ignores_vendor_trees() {
708    let suite = Suite::new();
709    suite.file(
710        "sub/good_test.js",
711        "import { test } from 'avril:test'; test('good', () => {});",
712    );
713    suite.file(
714        "sub/plain.js",
715        "import { test } from 'avril:test'; test('explicit', () => {});",
716    );
717    suite.file(
718        "node_modules/evil_test.js",
719        "import { test } from 'avril:test'; test('evil', () => { throw new Error('must not run'); });",
720    );
721    suite.file(
722        "vendor/evil_test.js",
723        "import { test } from 'avril:test'; test('evil', () => { throw new Error('must not run'); });",
724    );
725    // Walking finds only the test-shaped file; the explicit plain.js runs anyway.
726    let out = suite.stdout(&["sub"]);
727    assert!(out.contains("Check ./sub/good_test.js"), "{out}");
728    assert!(!out.contains("explicit"), "{out}");
729    let out = suite.stdout(&["sub/plain.js"]);
730    assert!(out.contains("ok - explicit"), "{out}");
731    let out = suite.stdout(&[]);
732    assert!(out.contains("Check ./sub/good_test.js"), "{out}");
733    assert!(!out.contains("evil"), "{out}");
734}
735
736#[test]
737fn test_help_and_option_validation_follow_command_conventions() {
738    let suite = Suite::new();
739    let help = String::from_utf8(
740        Command::new(env!("CARGO_BIN_EXE_avril"))
741            .arg("test")
742            .arg("--help")
743            .output()
744            .unwrap()
745            .stdout,
746    )
747    .unwrap();
748    assert!(
749        help.contains("Usage: avril test [OPTIONS] [files...]"),
750        "{help}"
751    );
752    assert!(help.contains("--filter=PATTERN"), "{help}");
753    assert!(help.contains("--no-run"), "{help}");
754    for (args, message) in [
755        (&["--bogus", "x"] as &[&str], "Unknown option: --bogus"),
756        (&["--pre", "x"], "--pre is not an option of `avril test`"),
757        (
758            &["--port=0", "x"],
759            "--port is not an option of `avril test`",
760        ),
761        (&["--filter"], "Expected a pattern for --filter"),
762        (&["--no-run=yes"], "--no-run does not take a value"),
763    ] {
764        let output = suite.test(args);
765        assert!(!output.status.success(), "{args:?}");
766        let error = String::from_utf8(output.stderr).unwrap();
767        assert!(error.contains(message), "{args:?}: {error}");
768    }
769}