From 8fc40e4c0f80c01f5044eb3539184217f1e6f12a Mon Sep 17 00:00:00 2001 From: Alex Crichton Date: Tue, 24 Jul 2018 11:32:18 -0700 Subject: [PATCH] Update test harness for browser testing This commit updates the test harness for in-browser testing. It now no longer unconditionally uses `fs.writeSync`, for example. Instead a `Formatter` trait is introduced for both Node/browser environments and at runtime we detect which is the appropriate one to use. --- crates/test/src/lib.rs | 2 +- crates/test/src/rt/browser.rs | 111 +++++++++++++++++++++++++++ crates/test/src/rt/detect.rs | 62 +++++++++++++++ crates/test/src/{rt.rs => rt/mod.rs} | 66 +++++++++------- crates/test/src/rt/node.rs | 60 +++++++++++++++ 5 files changed, 271 insertions(+), 30 deletions(-) create mode 100644 crates/test/src/rt/browser.rs create mode 100644 crates/test/src/rt/detect.rs rename crates/test/src/{rt.rs => rt/mod.rs} (83%) create mode 100644 crates/test/src/rt/node.rs diff --git a/crates/test/src/lib.rs b/crates/test/src/lib.rs index f0725975..25191d28 100644 --- a/crates/test/src/lib.rs +++ b/crates/test/src/lib.rs @@ -20,6 +20,6 @@ macro_rules! console_log { ) } -#[path = "rt.rs"] +#[path = "rt/mod.rs"] #[doc(hidden)] pub mod __rt; diff --git a/crates/test/src/rt/browser.rs b/crates/test/src/rt/browser.rs new file mode 100644 index 00000000..275c6378 --- /dev/null +++ b/crates/test/src/rt/browser.rs @@ -0,0 +1,111 @@ +//! Support for printing status information of a test suite in a browser. +//! +//! Currently this is quite simple, rendering the same as the console tests in +//! node.js. Output here is rendered in a `pre`, however. + +use wasm_bindgen::prelude::*; +use js_sys::Error; + +pub struct Browser { + pre: Element, +} + +#[wasm_bindgen] +extern { + type HTMLDocument; + static document: HTMLDocument; + #[wasm_bindgen(method, structural)] + fn getElementById(this: &HTMLDocument, id: &str) -> Element; + + type Element; + #[wasm_bindgen(method, getter = innerHTML, structural)] + fn inner_html(this: &Element) -> String; + #[wasm_bindgen(method, setter = innerHTML, structural)] + fn set_inner_html(this: &Element, html: &str); + + type BrowserError; + #[wasm_bindgen(method, getter, structural)] + fn stack(this: &BrowserError) -> JsValue; +} + +impl Browser { + pub fn new() -> Browser { + let pre = document.getElementById("output"); + pre.set_inner_html(""); + Browser { + pre, + } + } +} + +impl super::Formatter for Browser { + fn writeln(&self, line: &str) { + let mut html = self.pre.inner_html(); + html.push_str(&line); + html.push_str("\n"); + self.pre.set_inner_html(&html); + } + + fn log_start(&self, name: &str) { + let data = format!("test {} ... ", name); + let mut html = self.pre.inner_html(); + html.push_str(&data); + self.pre.set_inner_html(&html); + } + + fn log_success(&self) { + let mut html = self.pre.inner_html(); + html.push_str("ok\n"); + self.pre.set_inner_html(&html); + } + + fn log_ignored(&self) { + let mut html = self.pre.inner_html(); + html.push_str("ignored\n"); + self.pre.set_inner_html(&html); + } + + fn log_failure(&self, err: JsValue) -> String { + let mut html = self.pre.inner_html(); + html.push_str("FAIL\n"); + self.pre.set_inner_html(&html); + + // TODO: this should be a checked cast to `Error` + let err = Error::from(err); + let name = String::from(err.name()); + let message = String::from(err.message()); + let err = BrowserError::from(JsValue::from(err)); + let stack = err.stack(); + + let mut header = format!("{}: {}", name, message); + let stack = match stack.as_string() { + Some(stack) => stack, + None => return header, + }; + + // If the `stack` variable contains the name/message already, this is + // probably a chome-like error which is already rendered well, so just + // return this info + if stack.contains(&header) { + return stack + } + + // Check for a firefox-like error where all lines have a `@` in them + // separating the symbol and source + if stack.lines().all(|s| s.contains("@")) { + for line in stack.lines() { + header.push_str("\n"); + header.push_str(" at"); + for part in line.split("@") { + header.push_str(" "); + header.push_str(part); + } + } + return header + } + + // Fallback to make sure we don't lose any info + format!("{}\n{}", header, stack) + } +} + diff --git a/crates/test/src/rt/detect.rs b/crates/test/src/rt/detect.rs new file mode 100644 index 00000000..a90f571f --- /dev/null +++ b/crates/test/src/rt/detect.rs @@ -0,0 +1,62 @@ +use wasm_bindgen::prelude::*; +use js_sys::{Array, Function}; + +#[wasm_bindgen] +extern { + #[wasm_bindgen(js_name = Function)] + fn new_function(s: &str) -> Function; + + type This; + #[wasm_bindgen(method, getter, structural, js_name = self)] + fn self_(me: &This) -> JsValue; +} + +/// Returns whether it's likely we're executing in a browser environment, as +/// opposed to node.js. +pub fn is_browser() -> bool { + // This is a bit tricky to define. The basic crux of this is that we want to + // test if the `self` identifier is defined. That is defined in browsers + // (and web workers!) but not in Node. To that end you might expect: + // + // #[wasm_bindgen] + // extern { + // #[wasm_bindgen(js_name = self)] + // static SELF: JsValue; + // } + // + // *SELF != JsValue::undefined() + // + // this currently, however, throws a "not defined" error in JS because the + // generated function looks like `function() { return self; }` which throws + // an error in Node because `self` isn't defined. + // + // To work around this limitation we instead lookup the value of `self` + // through the `this` object, basically generating `this.self`. + // + // Unfortunately that's also hard to do! In ESM modes the top-level `this` + // object is undefined, meaning that we can't just generate a function that + // returns `this.self` as it'll throw "can't access field `self` of + // `undefined`" whenever ESMs are being used. + // + // So finally we reach the current implementation. According to + // StackOverflow you can access the global object via: + // + // const global = Function('return this')(); + // + // I think that's because the manufactured function isn't in "strict" mode. + // It also turns out that non-strict functions will ignore `undefined` + // values for `this` when using the `apply` function. Add it all up, and you + // get the below code: + // + // * Manufacture a function + // * Call `apply` where we specify `this` but the function ignores it + // * Once we have `this`, use a structural getter to get the value of `self` + // * Last but not least, test whether `self` is defined or not. + // + // Whew! + let this = new_function("return this") + .apply(&JsValue::undefined(), &Array::new()) + .unwrap(); + assert!(this != JsValue::undefined()); + This::from(this).self_() != JsValue::undefined() +} diff --git a/crates/test/src/rt.rs b/crates/test/src/rt/mod.rs similarity index 83% rename from crates/test/src/rt.rs rename to crates/test/src/rt/mod.rs index 746aa815..74eec717 100644 --- a/crates/test/src/rt.rs +++ b/crates/test/src/rt/mod.rs @@ -1,3 +1,5 @@ +#![doc(hidden)] + use std::cell::{RefCell, Cell}; use std::fmt; use std::mem; @@ -6,6 +8,10 @@ use console_error_panic_hook; use js_sys::{Array, Function}; use wasm_bindgen::prelude::*; +pub mod node; +pub mod browser; +pub mod detect; + /// Runtime test harness support instantiated in JS. /// /// The node.js entry script instantiates a `Context` here which is used to @@ -20,6 +26,15 @@ pub struct Context { current_log: RefCell, current_error: RefCell, ignore_this_test: Cell, + formatter: Box, +} + +trait Formatter { + fn writeln(&self, line: &str); + fn log_start(&self, name: &str); + fn log_success(&self); + fn log_ignored(&self); + fn log_failure(&self, err: JsValue) -> String; } #[wasm_bindgen] @@ -28,32 +43,26 @@ extern { #[doc(hidden)] pub fn console_log(s: &str); - // Not using `js_sys::Error` because node's errors specifically have a - // `stack` attribute. - type NodeError; - #[wasm_bindgen(method, getter, js_class = "Error", structural)] - fn stack(this: &NodeError) -> String; - // General-purpose conversion into a `String`. #[wasm_bindgen(js_name = String)] fn stringify(val: &JsValue) -> String; } -#[wasm_bindgen(module = "fs", version = "*")] -extern { - fn writeSync(fd: i32, data: &[u8]); -} - pub fn log(args: &fmt::Arguments) { console_log(&args.to_string()); } #[wasm_bindgen] + impl Context { #[wasm_bindgen(constructor)] pub fn new() -> Context { console_error_panic_hook::set_once(); + let formatter = match node::Node::new() { + Some(node) => Box::new(node) as Box, + None => Box::new(browser::Browser::new()), + }; Context { filter: None, current_test: RefCell::new(None), @@ -63,6 +72,7 @@ impl Context { current_log: RefCell::new(String::new()), current_error: RefCell::new(String::new()), ignore_this_test: Cell::new(false), + formatter, } } @@ -95,8 +105,8 @@ impl Context { args.push(&JsValue::from(self as *const Context as u32)); let noun = if tests.len() == 1 { "test" } else { "tests" }; - console_log!("running {} {}", tests.len(), noun); - console_log!(""); + self.formatter.writeln(&format!("running {} {}", tests.len(), noun)); + self.formatter.writeln(""); for test in tests { self.ignore_this_test.set(false); @@ -109,7 +119,7 @@ impl Context { self.log_success() } } - Err(e) => self.log_error(e.into()), + Err(e) => self.log_failure(e), } drop(self.current_test.borrow_mut().take()); *self.current_log.borrow_mut() = String::new(); @@ -123,22 +133,20 @@ impl Context { let mut current_test = self.current_test.borrow_mut(); assert!(current_test.is_none()); *current_test = Some(test.to_string()); - let data = format!("test {} ... ", test); - writeSync(2, data.as_bytes()); + self.formatter.log_start(test); } fn log_success(&self) { - writeSync(2, b"ok\n"); + self.formatter.log_success(); self.succeeded.set(self.succeeded.get() + 1); } fn log_ignore(&self) { - writeSync(2, b"ignored\n"); + self.formatter.log_ignored(); self.ignored.set(self.ignored.get() + 1); } - fn log_error(&self, err: NodeError) { - writeSync(2, b"FAILED\n"); + fn log_failure(&self, err: JsValue) { let name = self.current_test.borrow().as_ref().unwrap().clone(); let log = mem::replace(&mut *self.current_log.borrow_mut(), String::new()); let error = mem::replace(&mut *self.current_error.borrow_mut(), String::new()); @@ -154,25 +162,25 @@ impl Context { msg.push_str("\n"); } msg.push_str("JS exception that was thrown:\n"); - msg.push_str(&tab(&err.stack())); + msg.push_str(&tab(&self.formatter.log_failure(err))); self.failures.borrow_mut().push((name, msg)); } fn log_results(&self) { let failures = self.failures.borrow(); if failures.len() > 0 { - console_log!("\nfailures:\n"); + self.formatter.writeln("\nfailures:\n"); for (test, logs) in failures.iter() { - console_log!("---- {} output ----\n{}\n", test, tab(logs)); + let msg = format!("---- {} output ----\n{}", test, tab(logs)); + self.formatter.writeln(&msg); } - console_log!("failures:\n"); + self.formatter.writeln("failures:\n"); for (test, _) in failures.iter() { - console_log!(" {}\n", test); + self.formatter.writeln(&format!(" {}", test)); } - } else { - console_log!(""); } - console_log!( + self.formatter.writeln(""); + self.formatter.writeln(&format!( "test result: {}. \ {} passed; \ {} failed; \ @@ -181,7 +189,7 @@ impl Context { self.succeeded.get(), failures.len(), self.ignored.get(), - ); + )); } pub fn console_log(&self, original: &Function, args: &Array) { diff --git a/crates/test/src/rt/node.rs b/crates/test/src/rt/node.rs new file mode 100644 index 00000000..3f9cdbdd --- /dev/null +++ b/crates/test/src/rt/node.rs @@ -0,0 +1,60 @@ +//! Support for printing status information of a test suite in node.js +//! +//! This currently uses the same output as `libtest`, only reimplemented here +//! for node itself. + +use wasm_bindgen::prelude::*; +use js_sys::eval; + +pub struct Node { + fs: NodeFs, +} + +#[wasm_bindgen] +extern { + type NodeFs; + #[wasm_bindgen(method, js_name = writeSync, structural)] + fn write_sync(this: &NodeFs, fd: i32, data: &[u8]); + + // Not using `js_sys::Error` because node's errors specifically have a + // `stack` attribute. + type NodeError; + #[wasm_bindgen(method, getter, js_class = "Error", structural)] + fn stack(this: &NodeError) -> String; +} + +impl Node { + pub fn new() -> Option { + if super::detect::is_browser() { + return None + } + + let import = eval("require(\"fs\")").unwrap(); + Some(Node { fs: import.into() }) + } +} + +impl super::Formatter for Node { + fn writeln(&self, line: &str) { + super::console_log(line); + } + + fn log_start(&self, name: &str) { + let data = format!("test {} ... ", name); + self.fs.write_sync(2, data.as_bytes()); + } + + fn log_success(&self) { + self.fs.write_sync(2, b"ok\n"); + } + + fn log_ignored(&self) { + self.fs.write_sync(2, b"ignored\n"); + } + + fn log_failure(&self, err: JsValue) -> String { + self.fs.write_sync(2, b"ignored\n"); + // TODO: should do a checked cast to `NodeError` + NodeError::from(err).stack() + } +}