char/avril

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

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

Charlotte Somdeslop: consolidate public type and registry test utils7325e8c

main
15.1 KiB482 linesraw
1use std::rc::Rc;
2use std::sync::{Arc, Mutex};
3
4use avril_core::{Extension, Op, PublicModule};
5
6use avril_ext_stdio::Stdio;
7
8/// Harness options shared by every per-file runtime of one `avril test` run.
9#[derive(Clone, Default)]
10pub struct Config {
11    pub filter: Option<String>,
12    pub no_run: bool,
13}
14
15#[derive(Clone, Copy, Default)]
16struct Counts {
17    passed: usize,
18    failed: usize,
19    ignored: usize,
20    filtered: usize,
21    found: usize,
22}
23
24struct File {
25    display: String,
26    counts: Counts,
27    error: Option<String>,
28}
29
30trait Files {
31    fn files(self) -> String;
32}
33
34/// Reporter colors follow `console` so test verdicts match `avril add`:
35/// colored only for a terminal without `NO_COLOR`. All test output goes to
36/// stdout, so only stdout decides.
37fn use_colors() -> bool {
38    avril_ext_console::colors(false)
39}
40
41fn paint(colors: bool, style: &str, text: &str) -> String {
42    if colors {
43        format!("\x1b[{style}m{text}\x1b[0m")
44    } else {
45        text.to_owned()
46    }
47}
48
49impl Files for usize {
50    fn files(self) -> String {
51        if self == 1 {
52            "1 file".to_owned()
53        } else {
54            format!("{self} files")
55        }
56    }
57}
58
59/// Reporter state shared across the sequential per-file runtimes of a run.
60/// Guest test results arrive through `op_test_event` while its file runs.
61pub struct Session {
62    config: Config,
63    stdio: Rc<Stdio>,
64    files: Vec<File>,
65    watch: Arc<Mutex<WatchState>>,
66}
67
68/// Per-file watchdog state, shared with the thread holding the isolate handle.
69/// The guest runner arms deadlines through `start` events; the thread
70/// terminates execution when one passes (a spinning test never yields to the
71/// runner's own timers). `done` and test-end events re-arm or clear them.
72pub struct WatchState {
73    deadline: Option<std::time::Instant>,
74    running: Option<(String, u64)>,
75    stopped: bool,
76    fired: bool,
77}
78
79/// What a fired watchdog interrupted.
80pub enum WatchdogOutcome {
81    /// A test overran its own timeout while spinning.
82    Casualty { name: String, timeout_ms: u64 },
83    /// Nothing was running: top-level evaluation or a gap between tests
84    /// made no progress within the file budget.
85    Backstop,
86}
87
88impl WatchState {
89    fn reset(&mut self) {
90        self.deadline = None;
91        self.running = None;
92        self.stopped = false;
93        self.fired = false;
94    }
95
96    /// Tells the watcher thread to exit; the run calls this after every file.
97    pub fn shutdown(&mut self) {
98        self.stopped = true;
99    }
100
101    /// Whether an armed deadline has passed while the thread is still wanted.
102    pub fn expired(&self) -> bool {
103        !self.stopped
104            && self
105                .deadline
106                .is_some_and(|deadline| std::time::Instant::now() >= deadline)
107    }
108
109    pub fn stopped(&self) -> bool {
110        self.stopped
111    }
112
113    /// Records that the isolate was terminated; called by the watcher thread.
114    pub fn terminated(&mut self) {
115        self.fired = true;
116    }
117
118    /// Arms a test's deadline: its own timeout plus slack for the runner's
119    /// timers to win every observable race, or the file budget when the test
120    /// opted out of timeouts.
121    fn arm_test(&mut self, name: String, timeout_ms: Option<u64>) {
122        let budget = timeout_ms.map_or(FILE_BUDGET, |ms| {
123            std::time::Duration::from_millis(ms) + WATCHDOG_SLACK
124        });
125        self.running = Some((name, timeout_ms.unwrap_or(0)));
126        self.deadline = Some(std::time::Instant::now() + budget);
127    }
128
129    fn arm_file(&mut self) {
130        self.running = None;
131        self.deadline = Some(std::time::Instant::now() + FILE_BUDGET);
132    }
133
134    fn disarm(&mut self) {
135        self.running = None;
136        self.deadline = None;
137    }
138
139    /// What the watchdog interrupted, if it fired.
140    pub fn outcome(&mut self) -> Option<WatchdogOutcome> {
141        if !self.fired {
142            return None;
143        }
144        Some(match self.running.take() {
145            Some((name, timeout_ms)) => WatchdogOutcome::Casualty { name, timeout_ms },
146            None => WatchdogOutcome::Backstop,
147        })
148    }
149}
150
151/// Extra wall time after a test's own timeout before the watchdog treats it
152/// as spinning: the runner's timers win every race they can observe.
153const WATCHDOG_SLACK: std::time::Duration = std::time::Duration::from_secs(2);
154/// Progress budget for a file with no running test (top-level evaluation,
155/// gaps between tests): afterwards the file is terminated outright.
156const FILE_BUDGET: std::time::Duration = std::time::Duration::from_secs(60);
157
158pub type Shared = Rc<Mutex<Session>>;
159
160impl Session {
161    pub fn new(config: Config, stdio: Rc<Stdio>) -> Shared {
162        Rc::new(Mutex::new(Self {
163            config,
164            stdio,
165            files: Vec::new(),
166            watch: Arc::new(Mutex::new(WatchState {
167                deadline: None,
168                running: None,
169                stopped: false,
170                fired: false,
171            })),
172        }))
173    }
174
175    /// Hands the current file's watchdog state to its watcher thread.
176    /// Resets any previous file's outcome first, then re-arms the file
177    /// budget: reset() would otherwise leave top-level evaluation with no
178    /// deadline until the first test starts.
179    pub fn arm_watchdog(&mut self) -> Arc<Mutex<WatchState>> {
180        if let Ok(mut watch) = self.watch.lock() {
181            watch.reset();
182            watch.arm_file();
183        }
184        self.watch.clone()
185    }
186
187    pub fn begin_file(&mut self, display: String) {
188        let colors = use_colors();
189        let _ = self
190            .stdio
191            .print(false, format!("Check {}\n", paint(colors, "1", &display)));
192        self.files.push(File {
193            display,
194            counts: Counts::default(),
195            error: None,
196        });
197        // Top-level evaluation gets the file budget until the first test
198        // arms its own deadline.
199        if let Ok(mut watch) = self.watch.lock() {
200            watch.arm_file();
201        }
202    }
203
204    pub fn end_file(&mut self, error: Option<String>) {
205        let Some(file) = self.files.last_mut() else {
206            return;
207        };
208        file.error = error;
209        let counts = file.counts;
210        let colors = use_colors();
211        let line = if let Some(error) = &file.error {
212            // File errors never reach a test: prepare and evaluation failures
213            // surface here, so the footer carries their detail. Runner frames
214            // add nothing, as in per-test stacks.
215            let mut line = format!(
216                "{} - {}\n",
217                paint(colors, "1;31", "FAILED"),
218                paint(colors, "1", &file.display),
219            );
220            for text in error.lines() {
221                if text.contains("ext:test/runner.js")
222                    || text.contains("ext:test/main.js")
223                    || text.contains("ext:test/shared.js")
224                {
225                    continue;
226                }
227                line.push_str(&format!("  {text}\n"));
228            }
229            line
230        } else if self.config.no_run {
231            format!(
232                "{} - {} ({} tests, not run)\n",
233                paint(colors, "36", "found"),
234                paint(colors, "1", &file.display),
235                counts.found,
236            )
237        } else {
238            let verdict = if counts.failed == 0 { "ok" } else { "FAILED" };
239            let style = if counts.failed == 0 { "1;32" } else { "1;31" };
240            format!(
241                "{} - {} ({} passed, {} failed, {} ignored, {} filtered)\n",
242                paint(colors, style, verdict),
243                paint(colors, "1", &file.display),
244                counts.passed,
245                counts.failed,
246                counts.ignored,
247                counts.filtered,
248            )
249        };
250        let _ = self.stdio.print(false, line);
251    }
252
253    fn event(&mut self, event: TestEvent) {
254        // The runner's bookkeeping never reaches the report.
255        if event.kind == "start" {
256            if event.depth == 0
257                && let Ok(mut watch) = self.watch.lock()
258            {
259                watch.arm_test(event.name, event.timeout_ms);
260            }
261            return;
262        }
263        if event.kind == "done" {
264            if let Ok(mut watch) = self.watch.lock() {
265                watch.disarm();
266            }
267            return;
268        }
269        let Some(file) = self.files.last_mut() else {
270            return;
271        };
272        // Steps report indented under their test but never touch the file
273        // totals: only top-level tests count, as in the final summary.
274        let count = event.depth == 0;
275        let indent = "  ".repeat(event.depth.min(8));
276        let colors = use_colors();
277        let line = match event.kind.as_str() {
278            "pass" => {
279                if count {
280                    file.counts.passed += 1;
281                }
282                format!(
283                    "{indent}{} - {} ({}ms)\n",
284                    paint(colors, "1;32", "ok"),
285                    event.name,
286                    event.elapsed_ms,
287                )
288            }
289            "fail" => {
290                if count {
291                    file.counts.failed += 1;
292                }
293                let mut line = format!(
294                    "{indent}{} - {} ({}ms)\n",
295                    paint(colors, "1;31", "FAILED"),
296                    event.name,
297                    event.elapsed_ms,
298                );
299                for text in event.text.lines() {
300                    line.push_str(&format!("{indent}  {text}\n"));
301                }
302                line
303            }
304            "ignore" => {
305                if count {
306                    file.counts.ignored += 1;
307                }
308                format!(
309                    "{indent}{} - {}\n",
310                    paint(colors, "33", "ignored"),
311                    event.name,
312                )
313            }
314            "filtered" => {
315                file.counts.filtered += 1;
316                String::new()
317            }
318            "found" => {
319                file.counts.found += 1;
320                format!("{indent}{} - {}\n", paint(colors, "36", "test"), event.name,)
321            }
322            _ => String::new(),
323        };
324        if !line.is_empty() {
325            let _ = self.stdio.print(false, line);
326        }
327        // Between tests the file budget guards gaps in the runner itself.
328        if count
329            && matches!(event.kind.as_str(), "pass" | "fail" | "ignore")
330            && let Ok(mut watch) = self.watch.lock()
331        {
332            watch.arm_file();
333        }
334    }
335
336    /// Reports a test the watchdog terminated while spinning: the failure the
337    /// runner's own timers could never observe. Reads like any other timeout.
338    pub fn timeout_casualty(&mut self, name: String, timeout_ms: u64) {
339        self.event(TestEvent {
340            kind: "fail".to_owned(),
341            name: name.clone(),
342            depth: 0,
343            elapsed_ms: timeout_ms,
344            text: format!("Error: Test \"{name}\" timed out after {timeout_ms}ms"),
345            timeout_ms: None,
346        });
347    }
348
349    /// Prints the final summary; true when the run failed.
350    pub fn finish(&self) -> bool {
351        let mut passed = 0;
352        let mut failed = 0;
353        let mut ignored = 0;
354        let mut filtered = 0;
355        let mut found = 0;
356        let mut errors = 0;
357        for file in &self.files {
358            passed += file.counts.passed;
359            failed += file.counts.failed;
360            ignored += file.counts.ignored;
361            filtered += file.counts.filtered;
362            found += file.counts.found;
363            errors += usize::from(file.error.is_some());
364        }
365        let failed_run = failed > 0 || errors > 0;
366        let files = self.files.len();
367        let colors = use_colors();
368        let verdict = if failed_run { "FAILED" } else { "ok" };
369        let style = if failed_run { "1;31" } else { "1;32" };
370        let line = if self.config.no_run {
371            format!(
372                "test result: {}. found {found} tests in {} (not run).\n",
373                paint(colors, style, verdict),
374                files.files(),
375            )
376        } else {
377            format!(
378                "test result: {}. {passed} passed; {failed} failed; {ignored} ignored; {filtered} filtered; {}.\n",
379                paint(colors, style, verdict),
380                files.files(),
381            )
382        };
383        let _ = self.stdio.print(false, line);
384        failed_run
385    }
386}
387
388#[derive(serde::Serialize)]
389#[serde(rename_all = "camelCase")]
390struct ConfigSnapshot {
391    filter: Option<String>,
392    no_run: bool,
393}
394
395#[derive(serde::Deserialize)]
396#[serde(rename_all = "camelCase")]
397struct TestEvent {
398    kind: String,
399    #[serde(default)]
400    name: String,
401    #[serde(default)]
402    depth: usize,
403    #[serde(default)]
404    elapsed_ms: u64,
405    #[serde(default)]
406    text: String,
407    #[serde(default)]
408    timeout_ms: Option<u64>,
409}
410
411pub fn init(session: Shared) -> Extension {
412    let config = session.clone();
413    let events = session.clone();
414    Extension {
415        name: "ext:test",
416        dependencies: &["ext:timers", "ext:web"],
417        ops: vec![
418            Op::new("op_test_config", move |_: ()| {
419                let session = config
420                    .lock()
421                    .map_err(|_| std::io::Error::other("Test session is unavailable"))?;
422                Ok::<_, std::io::Error>(ConfigSnapshot {
423                    filter: session.config.filter.clone(),
424                    no_run: session.config.no_run,
425                })
426            }),
427            Op::new("op_test_event", move |event: TestEvent| {
428                // Worker isolates use an idle session nobody reads.
429                events
430                    .lock()
431                    .map_err(|_| std::io::Error::other("Test session is unavailable"))?
432                    .event(event);
433                Ok::<_, std::io::Error>(())
434            }),
435        ],
436        modules: const {
437            &[
438                (
439                    "ext:test/runner.js",
440                    include_str!(concat!(env!("OUT_DIR"), "/runner.js")),
441                ),
442                (
443                    "ext:test/shared.js",
444                    include_str!(concat!(env!("OUT_DIR"), "/shared.js")),
445                ),
446                PUBLIC_MODULES[0].module,
447            ]
448        },
449        entry_point: Some("ext:test/main.js"),
450    }
451}
452
453pub const PUBLIC_MODULES: &[PublicModule] =
454    &[avril_core::public_module!("avril:test", "ext:test", "main")];
455
456/// A throwaway session for runtimes outside `avril test`, where `test()`
457/// registers harmlessly and the runner never executes.
458pub fn idle(stdio: &Rc<Stdio>) -> Shared {
459    Session::new(Config::default(), stdio.clone())
460}
461
462#[cfg(test)]
463mod tests {
464    use super::paint;
465
466    #[test]
467    fn disabled_colors_leave_text_untouched() {
468        assert_eq!(paint(false, "1;32", "ok"), "ok");
469        assert_eq!(paint(false, "1;31", "FAILED - name"), "FAILED - name");
470    }
471
472    #[test]
473    fn enabled_colors_wrap_text_in_a_reset_pair() {
474        for (style, text) in [("1;32", "ok"), ("1;31", "FAILED"), ("33", "ignored")] {
475            let painted = paint(true, style, text);
476            assert!(painted.starts_with(&format!("\x1b[{style}m")));
477            assert!(painted.ends_with("\x1b[0m"));
478            assert!(painted.contains(text));
479            assert_ne!(painted, text);
480        }
481    }
482}