mod common; use std::process::{Command, Output, Stdio}; struct Suite { dir: tempfile::TempDir, } impl Suite { fn new() -> Self { Self { dir: tempfile::tempdir().unwrap(), } } fn file(&self, name: &str, source: &str) -> String { let path = self.dir.path().join(name); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(&path, source).unwrap(); path.to_str().unwrap().to_owned() } fn test(&self, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_avril")) .arg("test") .args(args) .current_dir(self.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map(common::wait) .unwrap() } fn stdout(&self, args: &[&str]) -> String { let output = self.test(args); assert!( output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap() } fn failure(&self, args: &[&str]) -> (String, String) { let output = self.test(args); assert!( !output.status.success(), "{args:?} unexpectedly passed: {}", String::from_utf8_lossy(&output.stdout) ); ( String::from_utf8(output.stdout).unwrap(), String::from_utf8(output.stderr).unwrap(), ) } } #[test] fn test_api_is_in_avril_test_not_avril_std() { let output = common::run( r#" import { test } from 'avril:test'; const testModule = await import('avril:test'); if (testModule.test !== test) throw new Error('Wrong test binding'); if (Object.keys(testModule).join(',') !== 'test') throw new Error('Leaked test internals'); for (const [fn, name] of [[test, 'test'], [test.only, 'only'], [test.ignore, 'ignore']]) { if (fn.name !== name || fn.length !== 2) throw new Error('Changed callable shape'); } for (const name of ['only', 'ignore']) { const descriptor = Object.getOwnPropertyDescriptor(test, name); if (!descriptor.writable || !descriptor.enumerable || !descriptor.configurable) { throw new Error('Changed test property descriptor'); } } const std = await import('avril:std'); if ('test' in std) throw new Error('avril:std still exports test'); test('inert under run', () => { throw new Error('must not run'); }); console.log('moved'); "#, &[], ); assert_eq!(output.stdout, b"moved\n"); } #[test] fn runner_captures_date_clock_before_guest_evaluation() { let suite = Suite::new(); suite.file( "poison_clock_test.js", r#" import { test } from "avril:test"; const originalNow = Date.now; Date.now = () => { throw new Error("runner captured the poisoned clock"); }; test("restore the clock", () => { Date.now = originalNow; }); "#, ); let out = suite.stdout(&["poison_clock_test.js"]); assert!(out.contains("ok - restore the clock"), "{out}"); } #[test] fn registration_uses_captured_array_methods() { let suite = Suite::new(); suite.file( "captured_test.ts", r#" import { test } from "avril:test"; const push = Array.prototype.push; Array.prototype.push = () => { throw new Error("poisoned push"); }; try { test("filtered", () => { throw new Error("must not run"); }); test.only("focused", async (t) => { if (!await t.step("child", () => {})) throw new Error("step failed"); }); test.only({ name: "ignored", ignore: true }); } finally { Array.prototype.push = push; } "#, ); let stdout = suite.stdout(&[]); assert!(stdout.contains("ok - focused"), "{stdout}"); assert!(stdout.contains("ok - child"), "{stdout}"); assert!( stdout.contains("1 passed; 0 failed; 1 ignored; 1 filtered"), "{stdout}" ); } #[test] fn passing_suite_reports_each_test_and_a_summary() { let suite = Suite::new(); suite.file( "math_test.ts", r#" import { test } from 'avril:test'; test('adds', () => { if (1 + 1 !== 2) throw new Error('math broke'); }); test('async works', async () => { await new Promise((resolve) => setTimeout(resolve, 5)); }); "#, ); let out = suite.stdout(&["math_test.ts"]); assert!(out.contains("Check ./math_test.ts"), "{out}"); assert!(out.contains("ok - adds"), "{out}"); assert!(out.contains("ok - async works"), "{out}"); assert!( out.contains("ok - ./math_test.ts (2 passed, 0 failed, 0 ignored, 0 filtered)"), "{out}" ); assert!( out.contains("test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered; 1 file."), "{out}" ); } #[test] fn durations_reflect_wall_time() { let suite = Suite::new(); suite.file( "slow_test.js", r#" import { test } from 'avril:test'; test('slow', async () => { await new Promise((resolve) => setTimeout(resolve, 200)); }); test({ name: 'no deadline', timeout: Infinity, fn() {} }); "#, ); let out = suite.stdout(&["slow_test.js"]); let line = out .lines() .find(|line| line.starts_with("ok - slow (")) .unwrap_or(""); let elapsed: u64 = line .strip_prefix("ok - slow (") .and_then(|rest| rest.strip_suffix("ms)")) .and_then(|digits| digits.parse().ok()) .unwrap_or(0); assert!(elapsed >= 150, "{out}"); assert!(out.contains("ok - no deadline ("), "{out}"); } #[test] fn spinning_tests_are_terminated_and_fail_their_file() { let suite = Suite::new(); suite.file( "spin_test.js", r#" import { test } from 'avril:test'; test({ name: 'spins', timeout: 50, fn() { for (;;) {} } }); test('never runs', () => {}); "#, ); suite.file( "after_test.js", r#" import { test } from 'avril:test'; test('unaffected', () => {}); "#, ); let (out, _) = suite.failure(&["spin_test.js", "after_test.js"]); assert!(out.contains("FAILED - spins (50ms)"), "{out}"); assert!(out.contains("Test \"spins\" timed out after 50ms"), "{out}"); assert!(!out.contains("never runs"), "{out}"); assert!(out.contains("ok - unaffected ("), "{out}"); assert!( out.contains("test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 filtered; 2 files."), "{out}" ); } #[test] fn timeouts_past_the_timer_range_are_rejected() { let suite = Suite::new(); suite.file( "huge_test.js", r#" import { test } from 'avril:test'; test({ name: 'huge', timeout: 3000000000, fn() {} }); "#, ); let (out, _) = suite.failure(&["huge_test.js"]); assert!(out.contains("at most 2147483647ms"), "{out}"); } #[test] fn steps_resolve_false_and_fail_parents_without_throwing() { let suite = Suite::new(); suite.file( "semantics_test.js", r#" import { test } from 'avril:test'; test('concurrent', async (t) => { const results = await Promise.all([ t.step('victim', () => {}), t.step('latecomer', () => {}), ]); if (JSON.stringify(results) !== '[true,false]') throw new Error('bad results'); }); test('unawaited', (t) => { t.step('late failure', async () => { await new Promise((resolve) => setTimeout(resolve, 10)); throw new Error('late boom'); }); }); test('ignored then real', async (t) => { // An ignored step must release the context for the next step. if ((await t.step({ name: 'skipped', ignore: true })) !== false) { throw new Error('bad ignore result'); } if ((await t.step('real', () => {})) !== true) throw new Error('real step blocked'); }); "#, ); let (out, _) = suite.failure(&["semantics_test.js"]); assert!(out.contains(" ok - victim ("), "{out}"); assert!(out.contains(" FAILED - latecomer ("), "{out}"); assert!(out.contains("while another step was running"), "{out}"); assert!(out.contains("FAILED - concurrent ("), "{out}"); assert!(out.contains("due to 1 failed step"), "{out}"); assert!(out.contains(" FAILED - late failure ("), "{out}"); assert!(out.contains("late boom"), "{out}"); assert!(out.contains("FAILED - unawaited ("), "{out}"); } #[test] fn stale_contexts_and_late_registrations_fail_loudly() { let suite = Suite::new(); suite.file( "stale_test.js", r#" import { test } from 'avril:test'; let stolen; test('first', (t) => { stolen = t; }); test('second', async () => { try { await stolen.step('late', () => {}); } catch (error) { if (!(error instanceof TypeError)) throw error; if (!error.message.includes('called after "first" settled')) throw error; return; } throw new Error('stale step was allowed'); }); test.ignore('nameless'); "#, ); let out = suite.stdout(&["stale_test.js"]); assert!(out.contains("ignored - nameless"), "{out}"); suite.file( "late_test.js", r#" import { test } from 'avril:test'; test('first', () => { test('late', () => {}); }); "#, ); let (out, _) = suite.failure(&["late_test.js"]); assert!( out.contains("must be called while the test file evaluates"), "{out}" ); } #[test] fn unhandled_rejections_fail_the_file_with_detail() { let suite = Suite::new(); suite.file( "stray_test.js", r#" import { test } from 'avril:test'; test('ok', () => {}); test('boom', () => { Promise.reject(new Error('lost rejection')); }); "#, ); let (out, _) = suite.failure(&["stray_test.js"]); assert!(out.contains("ok - ok ("), "{out}"); assert!( out.contains("Unhandled rejections during the test run:"), "{out}" ); assert!(out.contains("lost rejection"), "{out}"); } #[test] fn step_option_errors_name_steps_and_missing_functions() { let suite = Suite::new(); suite.file( "badopts_test.js", r#" import { test } from 'avril:test'; test('options', async (t) => { try { await t.step({ name: 's', fn() {}, bogus: true }); } catch (error) { if (error.message !== 'Unknown step option: bogus') throw error; } try { await t.step({ name: 's', fn() {}, ignore: 1 }); } catch (error) { if (error.message !== 'Step option ignore must be a boolean') throw error; return; } throw new Error('bad step options were allowed'); }); test('missing', async (t) => { try { await t.step('nofn'); } catch (error) { if (error.message !== 'Step "nofn" requires a function') throw error; return; } throw new Error('missing step function was allowed'); }); test({ name: 'sync only', timeout: 0, fn() {} }); "#, ); let out = suite.stdout(&["badopts_test.js"]); assert!(out.contains("3 passed"), "{out}"); } #[test] fn discovery_skips_dot_files_and_walks_symlinked_dirs() { let suite = Suite::new(); suite.file( "visible_test.js", "import { test } from 'avril:test'; test('seen', () => {});", ); suite.file( ".hidden_test.js", "import { test } from 'avril:test'; test('unseen', () => { throw new Error('ran'); });", ); suite.file( "real/inner_test.js", "import { test } from 'avril:test'; test('linked', () => {});", ); #[cfg(unix)] std::os::unix::fs::symlink(suite.dir.path().join("real"), suite.dir.path().join("link")) .unwrap(); // Walking the directory runs the visible file but never the dot-file. let out = suite.stdout(&["."]); assert!(out.contains("ok - seen ("), "{out}"); assert!(!out.contains("unseen"), "{out}"); #[cfg(unix)] { // An explicit symlinked directory walks its target. let out = suite.stdout(&["link"]); assert!(out.contains("ok - linked ("), "{out}"); } } #[test] fn failing_suite_fails_with_stacks_and_a_summary() { let suite = Suite::new(); suite.file( "fail_test.js", r#" import { test } from 'avril:test'; test('passes', () => {}); test('rejects', async () => { await Promise.resolve(); throw new Error('boom'); }); test('sync throws', () => { throw new TypeError('bad'); }); "#, ); let (out, err) = suite.failure(&["fail_test.js"]); assert!(out.contains("ok - passes"), "{out}"); assert!(out.contains("FAILED - rejects"), "{out}"); assert!(out.contains("FAILED - sync throws"), "{out}"); assert!(out.contains("Error: boom"), "{out}"); assert!(out.contains("TypeError: bad"), "{out}"); assert!(out.contains("fail_test.js:"), "{out}"); assert!(!out.contains("ext:test/main.js"), "{out}"); assert!(!out.contains("ext:test/runner.js"), "{out}"); assert!( out.contains("FAILED - ./fail_test.js (1 passed, 2 failed, 0 ignored, 0 filtered)"), "{out}" ); assert!( out.contains("test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 filtered; 1 file."), "{out}" ); assert!(err.contains("test failed"), "{err}"); } #[test] fn filter_selects_by_substring_and_counts_the_rest() { let suite = Suite::new(); suite.file( "filter_test.js", r#" import { test } from 'avril:test'; test('apple', () => {}); test('banana', () => {}); test('crabapple', () => {}); "#, ); let out = suite.stdout(&["--filter=apple", "filter_test.js"]); assert!(out.contains("ok - apple"), "{out}"); assert!(out.contains("ok - crabapple"), "{out}"); assert!(!out.contains("ok - banana"), "{out}"); assert!( out.contains("ok - ./filter_test.js (2 passed, 0 failed, 0 ignored, 1 filtered)"), "{out}" ); } #[test] fn ignore_skips_and_only_focuses() { let suite = Suite::new(); suite.file( "focus_test.js", r#" import { test } from 'avril:test'; test('a', () => {}); test({ name: 'b', ignore: true, fn() { throw new Error('must not run'); } }); test.ignore('c'); test('d', () => {}); "#, ); let out = suite.stdout(&["focus_test.js"]); assert!(out.contains("ignored - b"), "{out}"); assert!(out.contains("ignored - c"), "{out}"); assert!(out.contains("ok - a"), "{out}"); assert!(out.contains("ok - d"), "{out}"); assert!( out.contains("ok - ./focus_test.js (2 passed, 0 failed, 2 ignored, 0 filtered)"), "{out}" ); suite.file( "only_test.js", r#" import { test } from 'avril:test'; test('a', () => { throw new Error('must not run'); }); test.only('b', () => {}); test({ name: 'c', only: true, fn() {} }); "#, ); let out = suite.stdout(&["only_test.js"]); assert!(out.contains("ok - b"), "{out}"); assert!(out.contains("ok - c"), "{out}"); assert!(!out.contains("ok - a"), "{out}"); assert!( out.contains("ok - ./only_test.js (2 passed, 0 failed, 0 ignored, 1 filtered)"), "{out}" ); } #[test] fn steps_nest_and_a_failing_step_fails_its_parent() { let suite = Suite::new(); suite.file( "steps_test.js", r#" import { test } from 'avril:test'; test('nested', async (t) => { const first = await t.step('one', () => {}); const second = await t.step({ name: 'two', ignore: true, fn() {} }); if (first !== true || second !== false) throw new Error('bad step results'); await t.step('deep', async (t) => { await t.step('deeper', () => {}); }); }); test('step failure', async (t) => { await t.step('bad', () => { throw new Error('step boom'); }); }); "#, ); let (out, _) = suite.failure(&["steps_test.js"]); assert!(out.contains("ok - nested"), "{out}"); assert!(out.contains(" ok - one"), "{out}"); assert!(out.contains(" ignored - two"), "{out}"); assert!(out.contains(" ok - deep"), "{out}"); assert!(out.contains(" ok - deeper"), "{out}"); assert!(out.contains("FAILED - step failure"), "{out}"); assert!(out.contains(" FAILED - bad"), "{out}"); assert!(out.contains("step boom"), "{out}"); } #[test] fn slow_tests_time_out() { let suite = Suite::new(); suite.file( "timeout_test.js", r#" import { test } from 'avril:test'; test({ name: 'hangs', timeout: 30, fn: () => new Promise(() => {}) }); "#, ); let (out, _) = suite.failure(&["timeout_test.js"]); assert!(out.contains("FAILED - hangs"), "{out}"); assert!(out.contains("timed out after 30ms"), "{out}"); } #[test] fn files_are_isolated_but_run_in_sorted_order() { let suite = Suite::new(); suite.file( "a_first_test.js", r#" import { test } from 'avril:test'; test('sets global', () => { globalThis.__leak = 42; }); "#, ); suite.file( "b_second_test.js", r#" import { test } from 'avril:test'; test('sees no leak', () => { if (globalThis.__leak !== undefined) throw new Error('state leaked across files'); }); test('shares within a file', () => { globalThis.__local = 1; }); test('reads within a file', () => { if (globalThis.__local !== 1) throw new Error('no sharing within a file'); }); "#, ); let out = suite.stdout(&["a_first_test.js", "b_second_test.js"]); let first = out.find("Check ./a_first_test.js").unwrap(); let second = out.find("Check ./b_second_test.js").unwrap(); assert!(first < second, "{out}"); assert!( out.contains("test result: ok. 4 passed; 0 failed; 0 ignored; 0 filtered; 2 files."), "{out}" ); } #[test] fn permission_flags_apply_to_every_file() { let suite = Suite::new(); let secret = suite.file("secret.txt", "s3cret"); let grant = format!("--allow-read={secret}"); suite.file( "perm_test.js", &format!( r#" import {{ test }} from 'avril:test'; import {{ readTextFile }} from 'avril:fs'; test('reads the fixture', async () => {{ if ((await readTextFile({secret:?})) !== 's3cret') throw new Error('bad fixture'); }}); "# ), ); let (out, _) = suite.failure(&["-0", "perm_test.js"]); assert!(out.contains("FAILED - reads the fixture"), "{out}"); assert!(out.contains("PermissionDenied"), "{out}"); let out = suite.stdout(&["-0", &grant, "perm_test.js"]); assert!(out.contains("ok - reads the fixture"), "{out}"); } #[test] fn pledges_narrow_only_their_own_file() { let suite = Suite::new(); suite.file("fixture.txt", "data"); suite.file("helper.js", "export function triple(n) { return n * 3; }"); suite.file( "a_restrict_test.js", r#" import { pledge } from 'avril:std'; import { test } from 'avril:test'; pledge({ read: false, jsExec: false }); test('pure', () => { if (1 + 1 !== 2) throw new Error('bad'); }); "#, ); suite.file( "b_free_test.js", r#" import { test } from 'avril:test'; import { readTextFile } from 'avril:fs'; import { triple } from './helper.js'; test('reads and loads', async () => { if (triple(14) !== 42) throw new Error('bad helper'); if ((await readTextFile('./fixture.txt')) !== 'data') throw new Error('bad read'); }); "#, ); // The restricted file passes alone, the free file passes alone, and // both pass together: narrowing must not leak across files. suite.stdout(&["a_restrict_test.js"]); suite.stdout(&["b_free_test.js"]); let out = suite.stdout(&["a_restrict_test.js", "b_free_test.js"]); assert!( out.contains("test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered; 2 files."), "{out}" ); } #[test] fn file_errors_carry_detail_and_fail_the_run() { let suite = Suite::new(); suite.file("broken_test.js", "const = broken\n"); suite.file( "nofn_test.js", r#" import { test } from 'avril:test'; test('missing function'); "#, ); let (out, _) = suite.failure(&["broken_test.js", "nofn_test.js"]); assert!(out.contains("FAILED - ./broken_test.js"), "{out}"); assert!(out.contains("SyntaxError"), "{out}"); assert!(out.contains("FAILED - ./nofn_test.js"), "{out}"); assert!(out.contains("requires a function"), "{out}"); assert!(out.contains("test result: FAILED."), "{out}"); } #[test] fn shared_option_errors_keep_guest_frames_and_hide_test_internals() { let suite = Suite::new(); let registration = suite.file( "invalid_registration_test.js", r#" import { test } from "avril:test"; test({ name: "invalid", bogus: true }); "#, ); let step = suite.file( "invalid_step_test.js", r#" import { test } from "avril:test"; test("invalid step", (t) => t.step({ name: "invalid", bogus: true })); "#, ); let (out, _) = suite.failure(&[®istration, &step]); assert!(out.contains("Unknown test option: bogus"), "{out}"); assert!(out.contains("Unknown step option: bogus"), "{out}"); assert!(out.contains("invalid_registration_test.js:"), "{out}"); assert!(out.contains("invalid_step_test.js:"), "{out}"); for frame in [ "ext:test/main.js", "ext:test/runner.js", "ext:test/shared.js", ] { assert!(!out.contains(frame), "{out}"); } } #[test] fn no_run_lists_tests_without_running_them() { let suite = Suite::new(); suite.file( "list_test.js", r#" import { test } from 'avril:test'; test('would run', () => { throw new Error('must not run'); }); test({ name: 'would skip', ignore: true, fn() {} }); "#, ); let out = suite.stdout(&["--no-run", "list_test.js"]); assert!(out.contains("test - would run"), "{out}"); assert!(out.contains("ignored - would skip"), "{out}"); assert!(!out.contains("must not run"), "{out}"); assert!( out.contains("found - ./list_test.js (1 tests, not run)"), "{out}" ); assert!( out.contains("test result: ok. found 1 tests in 1 file (not run)."), "{out}" ); } #[test] fn discovery_walks_directories_and_ignores_vendor_trees() { let suite = Suite::new(); suite.file( "sub/good_test.js", "import { test } from 'avril:test'; test('good', () => {});", ); suite.file( "sub/plain.js", "import { test } from 'avril:test'; test('explicit', () => {});", ); suite.file( "node_modules/evil_test.js", "import { test } from 'avril:test'; test('evil', () => { throw new Error('must not run'); });", ); suite.file( "vendor/evil_test.js", "import { test } from 'avril:test'; test('evil', () => { throw new Error('must not run'); });", ); // Walking finds only the test-shaped file; the explicit plain.js runs anyway. let out = suite.stdout(&["sub"]); assert!(out.contains("Check ./sub/good_test.js"), "{out}"); assert!(!out.contains("explicit"), "{out}"); let out = suite.stdout(&["sub/plain.js"]); assert!(out.contains("ok - explicit"), "{out}"); let out = suite.stdout(&[]); assert!(out.contains("Check ./sub/good_test.js"), "{out}"); assert!(!out.contains("evil"), "{out}"); } #[test] fn test_help_and_option_validation_follow_command_conventions() { let suite = Suite::new(); let help = String::from_utf8( Command::new(env!("CARGO_BIN_EXE_avril")) .arg("test") .arg("--help") .output() .unwrap() .stdout, ) .unwrap(); assert!( help.contains("Usage: avril test [OPTIONS] [files...]"), "{help}" ); assert!(help.contains("--filter=PATTERN"), "{help}"); assert!(help.contains("--no-run"), "{help}"); for (args, message) in [ (&["--bogus", "x"] as &[&str], "Unknown option: --bogus"), (&["--pre", "x"], "--pre is not an option of `avril test`"), ( &["--port=0", "x"], "--port is not an option of `avril test`", ), (&["--filter"], "Expected a pattern for --filter"), (&["--no-run=yes"], "--no-run does not take a value"), ] { let output = suite.test(args); assert!(!output.status.success(), "{args:?}"); let error = String::from_utf8(output.stderr).unwrap(); assert!(error.contains(message), "{args:?}: {error}"); } }