char/avril

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

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

Charlotte Somadd progress to module imports from registries7b01146

main
15.1 KiB394 linesraw
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use avril_runtime::caps::Capabilities;
5
6use crate::cli::ParseError;
7use crate::fragments::{Arg, Entry, Parser, flag_value, reject};
8
9/// A discovered test target: a local file or an explicit remote URL.
10pub enum Target {
11    File(PathBuf),
12    Remote(String),
13}
14
15/// How one file's run concluded: cleanly (its tests reported themselves),
16/// or with a message for the file footer.
17enum FileResult {
18    Clean,
19    Failed(String),
20}
21
22/// Display a discovered file relative to the working directory when possible.
23pub fn display(cwd: &Path, path: &Path) -> String {
24    match path.strip_prefix(cwd) {
25        Ok(relative) if !relative.as_os_str().is_empty() => {
26            format!("./{}", relative.to_string_lossy().replace('\\', "/"))
27        }
28        _ => path.to_string_lossy().into_owned(),
29    }
30}
31
32fn is_test_file(path: &Path) -> bool {
33    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
34        return false;
35    };
36    // Dot-files stay out of discovery, matching the walked-directory rule.
37    if name.starts_with('.') {
38        return false;
39    }
40    if name.ends_with(".d.ts") {
41        return false;
42    }
43    let (stem, extension) = match name.rsplit_once('.') {
44        Some((stem, extension)) => (stem, extension),
45        None => return false,
46    };
47    if !matches!(
48        extension,
49        "js" | "mjs" | "cjs" | "cts" | "jsx" | "ts" | "mts" | "tsx"
50    ) {
51        return false;
52    }
53    stem == "test" || stem.ends_with("_test") || stem.ends_with(".test")
54}
55
56fn is_ignored_dir(name: &str) -> bool {
57    name.starts_with('.') || matches!(name, "node_modules" | "vendor" | "target" | "__pycache__")
58}
59
60fn walk(directory: &Path, files: &mut Vec<PathBuf>) {
61    let Ok(entries) = std::fs::read_dir(directory) else {
62        return;
63    };
64    let mut entries: Vec<_> = entries.filter_map(|entry| entry.ok()).collect();
65    entries.sort_by_key(|entry| entry.file_name());
66    for entry in entries {
67        let path = entry.path();
68        let file_type = match entry.file_type() {
69            Ok(file_type) => file_type,
70            Err(_) => continue,
71        };
72        // Never follow symlinks while walking: linked trees stay out of discovery.
73        if file_type.is_symlink() {
74            continue;
75        }
76        if file_type.is_dir() {
77            if is_ignored_dir(entry.file_name().to_string_lossy().as_ref()) {
78                continue;
79            }
80            walk(&path, files);
81        } else if file_type.is_file() && is_test_file(&path) {
82            files.push(path);
83        }
84    }
85}
86
87fn is_remote(argument: &str) -> bool {
88    // URL parsing (not a prefix check) so upper-case schemes match too.
89    avril_runtime::modules::http_url(argument).is_some()
90}
91
92/// Expand test arguments into canonical files and remote URLs.
93///
94/// No arguments walks the working directory. Explicit directories are walked;
95/// explicit files run even when their names do not match the test pattern.
96pub fn discover(cwd: &Path, args: &[String]) -> Result<Vec<Target>, String> {
97    let mut targets = Vec::new();
98    if args.is_empty() {
99        let mut files = Vec::new();
100        walk(cwd, &mut files);
101        targets.extend(files.into_iter().map(Target::File));
102    } else {
103        for argument in args {
104            if is_remote(argument) {
105                let url = url::Url::parse(argument).map_err(|error| error.to_string())?;
106                if !matches!(url.scheme(), "http" | "https") {
107                    return Err(format!("Invalid test URL: {argument}"));
108                }
109                targets.push(Target::Remote(url.to_string()));
110                continue;
111            }
112            let path = cwd.join(argument);
113            let metadata = path
114                .symlink_metadata()
115                .map_err(|_| format!("No such file or directory: {argument}"))?;
116            // An explicit symlink names its target: a linked directory walks
117            // like any explicit directory instead of failing at load time.
118            if metadata.is_symlink() {
119                let canonical = path.canonicalize().map_err(|error| error.to_string())?;
120                if canonical.is_dir() {
121                    let mut files = Vec::new();
122                    walk(&canonical, &mut files);
123                    targets.extend(files.into_iter().map(Target::File));
124                    continue;
125                }
126                targets.push(Target::File(canonical));
127                continue;
128            }
129            if metadata.is_dir() {
130                let canonical = path.canonicalize().map_err(|error| error.to_string())?;
131                let mut files = Vec::new();
132                walk(&canonical, &mut files);
133                targets.extend(files.into_iter().map(Target::File));
134            } else {
135                let canonical = path.canonicalize().map_err(|error| error.to_string())?;
136                targets.push(Target::File(canonical));
137            }
138        }
139    }
140    targets.sort_by(|a, b| match (a, b) {
141        (Target::File(a), Target::File(b)) => a.cmp(b),
142        (Target::Remote(a), Target::Remote(b)) => a.cmp(b),
143        (Target::File(_), Target::Remote(_)) => std::cmp::Ordering::Less,
144        (Target::Remote(_), Target::File(_)) => std::cmp::Ordering::Greater,
145    });
146    targets.dedup_by(|a, b| match (a, b) {
147        (Target::File(a), Target::File(b)) => a == b,
148        (Target::Remote(a), Target::Remote(b)) => a == b,
149        _ => false,
150    });
151    if targets.is_empty() {
152        return Err("No test files found".into());
153    }
154    Ok(targets)
155}
156
157pub struct TestArgs {
158    pub base: Entry,
159    pub args: Vec<String>,
160    pub targets: Vec<Target>,
161    pub filter: Option<String>,
162    pub no_run: bool,
163}
164
165/// `avril test` argument parsing failed.
166#[derive(Debug, thiserror::Error)]
167pub enum TestParseError {
168    #[error(transparent)]
169    Generic(#[from] ParseError),
170    #[error("{0}")]
171    Discover(String),
172}
173
174pub fn parse(
175    argv: Vec<String>,
176    script: Vec<String>,
177    cwd: PathBuf,
178) -> Result<TestArgs, TestParseError> {
179    let mut parser = Parser::new("test", argv, &cwd)?;
180    let (mut filter, mut no_run, mut positionals) = (None, false, Vec::new());
181    while let Some(arg) = parser.next()? {
182        match arg {
183            Arg::Positional(arg) => positionals.push(arg),
184            Arg::Flag(arg) if arg == "--filter" => {
185                filter = Some(parser.value(ParseError::FilterPattern)?);
186            }
187            Arg::Flag(arg) if arg == "--no-run" => no_run = true,
188            Arg::Flag(arg) => match flag_value(&arg)? {
189                ("--filter", value) => filter = Some(value.to_owned()),
190                _ => return Err(reject(&arg, &["--no-run"]).into()),
191            },
192        }
193    }
194    let mut targets = Vec::new();
195    for target in discover(&cwd, &positionals).map_err(TestParseError::Discover)? {
196        match &target {
197            Target::File(path) => parser.common.initial.entry(path),
198            Target::Remote(url) => {
199                let parsed = url::Url::parse(url).map_err(ParseError::Url)?;
200                let initial = &mut parser.common.initial;
201                initial.remote_entry(&parsed).map_err(ParseError::from)?;
202            }
203        }
204        targets.push(target);
205    }
206    // The lock path is resolved per file at run time (each file loads in
207    // its own package root); this placeholder only fills the field.
208    // project_root is likewise fixed per file later; explicit --lock
209    // never changes it. Path and module are placeholders too: per-file
210    // entries clone this base and set their own.
211    let base = parser
212        .common
213        .finish(cwd.clone(), String::new(), cwd.join("avril.lock"))?;
214    Ok(TestArgs {
215        base,
216        args: script,
217        targets,
218        filter,
219        no_run,
220    })
221}
222
223/// Runs every discovered test file in its own fresh isolate, sequentially,
224/// and prints the human-readable report shared with the guest runner.
225/// Mirrors how `run`/`serve` build their runtime. Each file gets a child of
226/// the CLI capabilities (so a pledge() in one file narrows only that file)
227/// with its own packages, store, and graph (so narrowing still applies to
228/// that file's loading exactly as under `run`). Returns Err on any failure
229/// so the process exits nonzero after the summary.
230pub fn run(args: &mut TestArgs) -> Result<(), Box<dyn std::error::Error>> {
231    let cwd = args.base.caps.cwd().to_owned();
232    // File paths must be valid UTF-8 before anything runs: a bad path aborts
233    // the whole run here, exactly as when the modules list was built up front.
234    for target in &args.targets {
235        if let Target::File(path) = target {
236            path.to_str().ok_or("Module paths must be valid UTF-8")?;
237        }
238    }
239    let stdio = avril_ext_stdio::Stdio::new()?;
240    // Declared before the JS runtimes so it outlives pending host work.
241    let host = avril_host::Host::new()?;
242    let broker: Arc<avril_host::messaging::Broker> = Arc::default();
243    let session = avril_ext_test::Session::new(
244        avril_ext_test::Config {
245            filter: args.filter.clone(),
246            no_run: args.no_run,
247        },
248        stdio.clone(),
249    );
250    for target in &args.targets {
251        // The upfront validation above makes the UTF-8 check below
252        // unreachable, keeping this loop total without panicking.
253        let (display, path, name) = match target {
254            Target::File(path) => (
255                display(&cwd, path),
256                path.clone(),
257                path.to_str()
258                    .ok_or("Module paths must be valid UTF-8")?
259                    .to_owned(),
260            ),
261            Target::Remote(url) => (url.clone(), cwd.join(".avril-remote.js"), url.clone()),
262        };
263        session
264            .lock()
265            .map_err(|error| error.to_string())?
266            .begin_file(display);
267        let result = (|| -> Result<FileResult, Box<dyn std::error::Error>> {
268            // A pledge() in this file must not narrow any other file: each
269            // file snapshots the CLI capabilities, so restrictions stay local.
270            let file = Entry {
271                caps: Capabilities::inherit(args.base.caps.clone()),
272                path,
273                module: name.clone(),
274                ..args.base.clone()
275            };
276            let (authority, root, packages) = file.project(false)?;
277            let storage =
278                avril_webstorage::Persistent::new(&packages.cache, &root.join("avril.conf"), &name);
279            let graph = crate::fragments::prepare_graph(
280                host.handle(),
281                authority,
282                packages,
283                &name,
284                &file.includes,
285                true,
286                None,
287            )?;
288            graph.set_entry(&name);
289            let mut runtime = avril_runtime::assembly::runtime(
290                graph.clone(),
291                avril_runtime::assembly::Settings {
292                    snapshot: Some(crate::snapshots::embedded()),
293                    storage,
294                    caps: file.caps.clone(),
295                    environment: file.environment.clone(),
296                    args: args.args.clone(),
297                    store: graph.store().clone(),
298                    runtime: host.handle(),
299                    broker: broker.clone(),
300                    process_signal: avril_ext_process::SignalMode::Run,
301                },
302                stdio.clone(),
303                None,
304                Some(session.clone()),
305            )?;
306            // A spinning test never yields to the runner's own timers, so a
307            // thread holding the isolate handle terminates it on overrun.
308            let watch = session
309                .lock()
310                .map_err(|error| error.to_string())?
311                .arm_watchdog();
312            let handle = runtime.isolate_handle();
313            let watcher = std::thread::Builder::new()
314                .name("avril:test-watchdog".to_owned())
315                .spawn({
316                    let watch = watch.clone();
317                    move || {
318                        loop {
319                            std::thread::sleep(std::time::Duration::from_millis(20));
320                            let Ok(mut state) = watch.lock() else {
321                                return;
322                            };
323                            if state.stopped() {
324                                return;
325                            }
326                            if state.expired() {
327                                handle.terminate_execution();
328                                state.terminated();
329                                return;
330                            }
331                        }
332                    }
333                })
334                .map_err(|error| error.to_string())?;
335            let outcome =
336                runtime.execute_module_with_bootstrap(&name, "ext:test/runner.js", "runTests");
337            if let Ok(mut state) = watch.lock() {
338                state.shutdown();
339            }
340            watcher.join().map_err(|_| "Test watchdog thread failed")?;
341            // A clean return settles every question the watchdog could ask:
342            // a termination landing afterwards hits an idle, dying isolate.
343            let fired = if outcome.is_err() {
344                match watch.lock() {
345                    Ok(mut state) => state.outcome(),
346                    Err(_) => None,
347                }
348            } else {
349                None
350            };
351            match (outcome, fired) {
352                // The runner's timers observed every failure themselves.
353                (Ok(()), _) => Ok(FileResult::Clean),
354                // A spinning test died by termination: report its timeout and
355                // fail the rest of the file with it. The raw termination
356                // error explains nothing, so it never reaches the report.
357                (Err(_), Some(avril_ext_test::WatchdogOutcome::Casualty { name, timeout_ms })) => {
358                    session
359                        .lock()
360                        .map_err(|error| error.to_string())?
361                        .timeout_casualty(name, timeout_ms);
362                    Ok(FileResult::Clean)
363                }
364                // No test was running: evaluation or the runner itself stalled.
365                (Err(_), Some(avril_ext_test::WatchdogOutcome::Backstop)) => {
366                    Ok(FileResult::Failed(
367                        "Test run made no progress for 60s; execution was terminated".to_owned(),
368                    ))
369                }
370                (Err(error), None) => Ok(FileResult::Failed(error.to_string())),
371            }
372        })();
373        stdio.flush()?;
374        match result {
375            Ok(FileResult::Clean) => session
376                .lock()
377                .map_err(|error| error.to_string())?
378                .end_file(None),
379            Ok(FileResult::Failed(message)) => session
380                .lock()
381                .map_err(|error| error.to_string())?
382                .end_file(Some(message)),
383            Err(error) => session
384                .lock()
385                .map_err(|error| error.to_string())?
386                .end_file(Some(error.to_string())),
387        }
388    }
389    stdio.flush()?;
390    if session.lock().map_err(|error| error.to_string())?.finish() {
391        return Err("test failed".into());
392    }
393    Ok(())
394}