Load configs through registration calls
The module exposes setup, server, and flow as Rust closures sharing a
registration stash, so function values cross as typed call arguments.
Setup deserializes with strict default options since its table is pure
data; compiling with correlate keeps error line numbers aligned to the
config after multi-statement forms.
Assisted-by: GLM-5.3 via pi
diff --git a/src/error.rs b/src/error.rs
index cc5c6fa..fc0f986 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -15,12 +15,12 @@ pub enum Error {
error: mlua::Error,
},
- #[error("{} must return (parallax {{...}})", .path.display())]
+ #[error("{} must call ({what} ...)", .path.display())]
#[diagnostic(
- code(parallax::config_return),
- help("make the config's last form a call to the parallax module")
+ code(parallax::config_registration),
+ help("a config registers with (setup {{...}}), (server fn), and (flow :name fn)")
)]
- ConfigReturn { path: PathBuf },
+ ConfigRegistration { path: PathBuf, what: &'static str },
#[error("io error: {0}")]
#[diagnostic(code(parallax::io))]
diff --git a/src/parallax.rs b/src/parallax.rs
index 42c5434..1322b91 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -1,28 +1,45 @@
+use std::mem;
use std::path::Path;
use std::path::PathBuf;
+use std::sync::Arc;
+use std::sync::Mutex;
+use std::sync::MutexGuard;
-use mlua::FromLua;
use mlua::Function;
use mlua::Lua;
+use mlua::LuaSerdeExt as _;
use mlua::Table;
use mlua::Value;
use crate::Error;
use crate::spec::Flow;
-use crate::spec::Spec;
+use crate::spec::NonEmptyString;
+use crate::spec::Setup;
const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
-/// A loaded parallax: the executable form of a config. The spec holds
-/// the flows and baseline as owned handles into the embedded Lua
-/// state, which stays alive in this struct; fields are ordered so the
-/// handles drop while the VM lives.
+/// What a config has registered so far, filled in by the module's
+/// registration functions as the config runs.
+#[derive(Default)]
+struct Registrations {
+ setup: Option<Setup>,
+ server: Option<Function>,
+ flows: Vec<Flow>,
+}
+
+/// A loaded parallax: the executable form of a config. The server
+/// function and flows are owned handles into the embedded Lua state,
+/// which stays alive in this struct; fields are ordered so the handles
+/// drop while the VM lives.
pub struct Parallax {
- // Consumed once running configs lands: the flows and baseline get
- // called, and the schema drives viewport loops and the server
- // supervisor.
+ flows: Vec<Flow>,
+ // Consumed once running configs lands: the server function is
+ // called once per viewport, and the setup drives viewport loops
+ // and the baseline download.
+ #[allow(dead_code)]
+ server: Function,
#[allow(dead_code)]
- spec: Spec,
+ setup: Setup,
name: String,
#[allow(dead_code)]
path: PathBuf,
@@ -37,12 +54,51 @@ impl Parallax {
// safe default omits. Configs are the user's own trusted files,
// so the full stdlib is fine here.
let lua = unsafe { Lua::unsafe_new() };
- let table = load_table(&lua, path, &source)?;
- build(lua, &table, path)
+ let fail = |error: mlua::Error| Error::Config {
+ path: path.to_path_buf(),
+ error,
+ };
+
+ let fennel: Table = lua
+ .load(FENNEL_LUA)
+ .set_name("fennel.lua")
+ .eval()
+ .map_err(fail)?;
+
+ let stash = Arc::new(Mutex::new(Registrations::default()));
+ install_module(&lua, &stash).map_err(fail)?;
+
+ let filename = path.to_string_lossy().into_owned();
+ run_config(&lua, &fennel, &source, &filename).map_err(fail)?;
+
+ let Registrations {
+ setup,
+ server,
+ flows,
+ } = mem::take(&mut *lock(&stash).map_err(fail)?);
+
+ let setup = setup.ok_or_else(|| Error::ConfigRegistration {
+ path: path.to_path_buf(),
+ what: "setup",
+ })?;
+ let server = server.ok_or_else(|| Error::ConfigRegistration {
+ path: path.to_path_buf(),
+ what: "server",
+ })?;
+ let name = setup.name.clone().unwrap_or_else(|| default_name(path));
+
+ Ok(Parallax {
+ flows,
+ server,
+ setup,
+ name,
+ path: path.to_path_buf(),
+ lua,
+ })
}
pub fn flows(&self) -> &[Flow] {
- self.spec.flows.as_slice()
+ &self.flows
}
pub fn name(&self) -> &str {
@@ -55,29 +111,6 @@ impl Parallax {
}
}
-/// Converts the validated table into a Spec. Conversion already ran
-/// inside the parallax call, so failures here are internal errors.
-fn build(lua: Lua, table: &Table, path: &Path) -> Result<Parallax, Error> {
- let fail = |error: mlua::Error| Error::Config {
- path: path.to_path_buf(),
- error,
- };
-
- let spec = Spec::from_lua(Value::Table(table.clone()), &lua).map_err(fail)?;
- let name = spec
- .schema
- .name
- .clone()
- .unwrap_or_else(|| default_name(path));
-
- Ok(Parallax {
- spec,
- name,
- path: path.to_path_buf(),
- lua,
- })
-}
-
fn default_name(path: &Path) -> String {
path.parent()
.and_then(Path::file_name)
@@ -86,51 +119,13 @@ fn default_name(path: &Path) -> String {
.to_owned()
}
-fn load_table(lua: &Lua, path: &Path, source: &str) -> Result<Table, Error> {
- let fail = |error: mlua::Error| Error::Config {
- path: path.to_path_buf(),
- error,
- };
-
- let fennel: Table = lua
- .load(FENNEL_LUA)
- .set_name("fennel.lua")
- .eval()
- .map_err(fail)?;
-
- install_module(lua).map_err(fail)?;
-
- let filename = path.to_string_lossy().into_owned();
- let value = run_config(lua, &fennel, source, &filename).map_err(fail)?;
-
- let table = match value {
- Value::Table(table) => table,
- _ => {
- return Err(Error::ConfigReturn {
- path: path.to_path_buf(),
- });
- }
- };
-
- let marked = table
- .metatable()
- .is_some_and(|metatable| metatable.get::<bool>("__parallax_config").unwrap_or(false));
- if !marked {
- return Err(Error::ConfigReturn {
- path: path.to_path_buf(),
- });
- }
-
- Ok(table)
-}
-
/// Builds the parallax module in Rust and registers it as the
/// `parallax` entry in package.preload, so configs can require it.
-fn install_module(lua: &Lua) -> Result<(), mlua::Error> {
+fn install_module(lua: &Lua, stash: &Arc<Mutex<Registrations>>) -> Result<(), mlua::Error> {
let module = lua.create_table()?;
- let metatable = lua.create_table()?;
- metatable.set("__call", parallax_call(lua)?)?;
- module.set_metatable(Some(metatable))?;
+ module.set("setup", setup_fn(lua, stash.clone())?)?;
+ module.set("server", server_fn(lua, stash.clone())?)?;
+ module.set("flow", flow_fn(lua, stash.clone())?)?;
let package: Table = lua.globals().get("package")?;
let preload: Table = package.get("preload")?;
@@ -139,41 +134,85 @@ fn install_module(lua: &Lua) -> Result<(), mlua::Error> {
Ok(())
}
-/// The __call metamethod: converts the spec table into a Spec and
-/// marks the result so the loader can tell a real config from any
-/// other table. Conversion happens while the config's stack frame is
-/// alive, so errors can point at the (parallax {...}) call site.
-fn parallax_call(lua: &Lua) -> Result<Function, mlua::Error> {
- lua.create_function(move |lua, (_module, cfg): (Table, Value)| {
- if let Err(error) = Spec::from_lua(cfg.clone(), lua) {
- return Err(error_at(lua, &error_message(&error)));
+/// The registration functions raise errors while the config's stack
+/// frame is alive, so failures point at the registration call site.
+fn setup_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
+ lua.create_function(move |lua, table: Value| {
+ let Value::Table(table) = table else {
+ return Err(error_at(lua, "setup takes a table"));
+ };
+ if lock(&stash)?.setup.is_some() {
+ return Err(error_at(lua, "setup must be called exactly once"));
+ }
+ let setup: Setup = lua
+ .from_value(Value::Table(table))
+ .map_err(|error| error_at(lua, &error_message(&error)))?;
+ if setup.viewports.is_empty() {
+ return Err(error_at(lua, "viewports must not be empty"));
}
- let Value::Table(table) = cfg else {
- return Err(error_at(lua, "parallax must be called with a table"));
+ lock(&stash)?.setup = Some(setup);
+ Ok(())
+ })
+}
+
+fn server_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
+ lua.create_function(move |lua, call: Value| {
+ let Value::Function(call) = call else {
+ return Err(error_at(lua, "server takes a function"));
};
- let marker = lua.create_table()?;
- marker.set("__parallax_config", true)?;
- table.set_metatable(Some(marker))?;
- Ok(table)
+ let mut registrations = lock(&stash)?;
+ if registrations.server.is_some() {
+ return Err(error_at(lua, "server must be called exactly once"));
+ }
+ registrations.server = Some(call);
+ Ok(())
})
}
+fn flow_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
+ lua.create_function(move |lua, (name, call): (Value, Value)| {
+ let name = match name {
+ Value::String(name) => match name.to_str() {
+ Ok(name) => NonEmptyString::new(name.to_owned()),
+ Err(_) => None,
+ },
+ _ => None,
+ };
+ let call = match call {
+ Value::Function(call) => Some(call),
+ _ => None,
+ };
+ let (Some(name), Some(call)) = (name, call) else {
+ return Err(error_at(lua, "flow takes a non-empty name and a function"));
+ };
+ lock(&stash)?.flows.push(Flow { name, call });
+ Ok(())
+ })
+}
+
+fn lock(stash: &Arc<Mutex<Registrations>>) -> Result<MutexGuard<'_, Registrations>, mlua::Error> {
+ stash
+ .lock()
+ .map_err(|_| mlua::Error::runtime("the registration state was poisoned"))
+}
+
fn error_at(lua: &Lua, message: &str) -> mlua::Error {
let position = caller_position(lua).unwrap_or_else(|| "?".to_string());
mlua::Error::runtime(format!("{position}: {message}"))
}
-/// Spec conversion errors are runtime errors carrying a bare message;
+/// Conversion errors are runtime errors carrying a bare message;
/// unwrap them so position prefixes don't stack Display prefixes.
fn error_message(error: &mlua::Error) -> String {
match error {
mlua::Error::RuntimeError(message) => message.clone(),
+ mlua::Error::DeserializeError(message) => message.clone(),
other => other.to_string(),
}
}
/// Walks the Lua stack for the config's main chunk so errors point at
-/// the (parallax {...}) call site. Runs inside the __call callback,
+/// the registration call site. Runs inside a registration callback,
/// where that frame is still alive.
fn caller_position(lua: &Lua) -> Option<String> {
let debug: Table = lua.globals().get("debug").ok()?;
@@ -196,14 +235,20 @@ fn run_config(
source: &str,
filename: &str,
) -> Result<Value, mlua::Error> {
- // Configs end with (parallax {...}), which compiles to a tail call;
- // tail elimination destroys the config's stack frame, hiding the
- // call site from conversion errors. Turning the final return into
- // a local assignment on the same line keeps the frame alive without
- // moving any other statement.
+ // Configs end with a registration call, which compiles to a tail
+ // call; tail elimination destroys the config's stack frame, hiding
+ // the call site from registration errors. Turning the final return
+ // into a local assignment on the same line keeps the frame alive
+ // without moving any other statement.
+ //
+ // `correlate` makes Fennel emit each statement on its source line,
+ // so debug line numbers match the config; a form like the
+ // destructured require emits several statements and would otherwise
+ // drift every following line.
let compile: Function = fennel.get("compile-string")?;
let options = lua.create_table()?;
options.set("filename", filename)?;
+ options.set("correlate", true)?;
let compiled: String = compile.call((source, options))?;
let compiled = protect_tail_call(&compiled);
lua.load(compiled).set_name(format!("@{filename}")).eval()
diff --git a/src/spec.rs b/src/spec.rs
index 27fbd11..62a0ec4 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -3,23 +3,8 @@
use std::fmt;
use std::num::NonZeroU32;
-use mlua::FromLua;
use mlua::Function;
-use mlua::Lua;
-use mlua::LuaSerdeExt as _;
-use mlua::Table;
-use mlua::Value;
use serde::Deserialize;
-use serde::de::IgnoredAny;
-
-/// Functions can't cross serde's data model — mlua skips them inside
-/// sequences and degrades them to unit values at map slots — so the
-/// scalar skeleton below is deserialized with serde while function-
-/// bearing fields convert through FromLua. This is also why flows and
-/// baseline are declared as ignored on the schema: they have to be
-/// known keys (deny_unknown_fields) without being serde-parsed.
-const OPTIONS: mlua::serde::DeserializeOptions =
- mlua::serde::DeserializeOptions::new().deny_unsupported_types(false);
/// A string that rejected its own emptiness at deserialization time.
#[derive(Debug)]
@@ -56,300 +41,140 @@ pub struct Flow {
pub call: Function,
}
-impl FromLua for Flow {
- fn from_lua(value: Value, _lua: &Lua) -> Result<Self, mlua::Error> {
- let Value::Table(pair) = value else {
- return Err(mlua::Error::runtime("must be a [name function] pair"));
- };
- let name = NonEmptyString::new(pair.get::<String>(1)?)
- .ok_or_else(|| mlua::Error::runtime("name must not be empty"))?;
- let call = pair.get::<Function>(2)?;
- Ok(Flow { name, call })
- }
-}
-
-/// A config's flows, in config order.
-#[derive(Debug)]
-pub struct Flows(Vec<Flow>);
-
-impl Flows {
- pub fn as_slice(&self) -> &[Flow] {
- &self.0
- }
-}
-
-impl FromLua for Flows {
- fn from_lua(value: Value, lua: &Lua) -> Result<Self, mlua::Error> {
- let Value::Table(table) = value else {
- return Err(mlua::Error::runtime(
- ":flows must be a sequence of [name function] pairs",
- ));
- };
- let mut flows = Vec::with_capacity(table.raw_len());
- for index in 1..=table.raw_len() {
- let flow = Flow::from_lua(table.get(index)?, lua).map_err(|_| {
- mlua::Error::runtime(format!(":flows[{index}] must be a [name function] pair"))
- })?;
- flows.push(flow);
- }
- Ok(Flows(flows))
- }
-}
-
-/// The optional baseline function.
-#[derive(Debug)]
-pub struct Baseline(Option<Function>);
+/// The table passed to `(setup {...})`. Pure data — a function
+/// anywhere in it is an error, which is why it deserializes with
+/// mlua's default strict options.
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(dead_code)] // baseline is read once the GitHub task lands
+pub(crate) struct Setup {
+ pub(crate) viewports: Vec<(NonEmptyString, NonZeroU32, NonZeroU32)>,
-impl Baseline {
- // Called once running configs lands.
- #[allow(dead_code)]
- pub(crate) fn as_function(&self) -> Option<&Function> {
- self.0.as_ref()
- }
-}
+ #[serde(default)]
+ pub(crate) baseline: Option<BaselineSource>,
-impl FromLua for Baseline {
- fn from_lua(value: Value, _lua: &Lua) -> Result<Self, mlua::Error> {
- match value {
- Value::Nil => Ok(Baseline(None)),
- Value::Function(call) => Ok(Baseline(Some(call))),
- _ => Err(mlua::Error::runtime(":baseline must be a function")),
- }
- }
+ #[serde(default)]
+ pub(crate) name: Option<String>,
}
-/// The scalar skeleton of a config, deserialized from the validated
-/// table.
+/// Where comparison snapshots come from; see the design doc's GitHub
+/// releases section.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
-#[allow(dead_code)] // read by tests now, by the runner once run lands
-pub(crate) struct Schema {
- pub(crate) viewports: Vec<(NonEmptyString, NonZeroU32, NonZeroU32)>,
- pub(crate) server: Server,
-
- pub(crate) baseline: Option<IgnoredAny>,
+#[allow(dead_code)] // read once the GitHub task lands
+pub(crate) struct BaselineSource {
+ pub(crate) repo: NonEmptyString,
- pub(crate) flows: IgnoredAny,
-
- pub(crate) name: Option<String>,
+ #[serde(default)]
+ pub(crate) asset: Option<NonEmptyString>,
}
+/// The table the registered server function returns, once per
+/// viewport. Validated when the function is called — a future task.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
-#[allow(dead_code)] // read by tests now, by the runner once run lands
-pub(crate) struct Server {
- pub(crate) cmd: Vec<String>,
+#[allow(dead_code)] // constructed when running configs lands
+pub struct ServerSpec {
+ pub cmd: Vec<String>,
#[serde(default = "default_timeout")]
- pub(crate) timeout: f64,
+ pub timeout: f64,
}
+/// Referenced by ServerSpec's serde default; constructed when running configs lands.
+#[allow(dead_code)]
fn default_timeout() -> f64 {
30.0
}
-/// Everything a config's parallax call produces: the deserialized
-/// schema plus the typed values serde can't carry.
-#[derive(Debug)]
-pub struct Spec {
- pub(crate) schema: Schema,
- pub(crate) flows: Flows,
- // Called once running configs lands.
- #[allow(dead_code)]
- pub(crate) baseline: Baseline,
-}
-
-impl FromLua for Spec {
- fn from_lua(value: Value, lua: &Lua) -> Result<Self, mlua::Error> {
- let schema: Schema = lua
- .from_value_with(value.clone(), OPTIONS)
- .map_err(|error| {
- match error {
- // Strip mlua's wrapper so the bare serde message rides
- // out; the caller adds the call-site position.
- mlua::Error::DeserializeError(message) => mlua::Error::runtime(message),
- other => other,
- }
- })?;
- if schema.viewports.is_empty() {
- return Err(mlua::Error::runtime("viewports must not be empty"));
- }
- if schema.server.cmd.is_empty() {
- return Err(mlua::Error::runtime("server.cmd must not be empty"));
- }
- let table = Table::from_lua(value, lua)?;
- let flows = Flows::from_lua(table.get("flows")?, lua)?;
- let baseline = Baseline::from_lua(table.get("baseline")?, lua)?;
- Ok(Spec {
- schema,
- flows,
- baseline,
- })
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
+ use mlua::Lua;
+ use mlua::LuaSerdeExt as _;
+ use mlua::Value;
- const VIEWPORTS: &str = r#"viewports = {{"desktop", 1280, 900}}"#;
- const SERVER: &str = r#"server = {cmd = {"true"}}"#;
- const FLOWS: &str = r#"flows = {{"flow", function() end}}"#;
-
- fn with_spec<T>(body: &str, check: impl FnOnce(&Lua, &Table) -> T) -> T {
+ fn with_value<T>(body: &str, check: impl FnOnce(&Lua, Value) -> T) -> T {
let lua = unsafe { Lua::unsafe_new() };
- let table: Table = lua
- .load(format!("return {{{body}}}"))
- .eval()
- .expect("lua table source");
- check(&lua, &table)
- }
-
- fn spec(lua: &Lua, table: &Table) -> Result<Spec, String> {
- Spec::from_lua(Value::Table(table.clone()), lua).map_err(|error| error.to_string())
- }
-
- fn assert_message(body: &str, fragment: &str) {
- with_spec(body, |lua, table| {
- let error = spec(lua, table).expect_err("expected an invalid config");
- assert!(
- error.contains(fragment),
- "expected {error:?} to contain {fragment:?}"
- );
- });
+ let value: Value = lua.load(body).eval().expect("lua source");
+ check(&lua, value)
}
#[test]
- fn accepts_a_valid_config() {
- with_spec(&format!("{VIEWPORTS}, {SERVER}, {FLOWS}"), |lua, table| {
- let spec = spec(lua, table).expect("valid config");
- assert_eq!(spec.schema.viewports.len(), 1);
- assert_eq!(spec.flows.as_slice().len(), 1);
- assert_eq!(spec.flows.as_slice()[0].name.as_str(), "flow");
- assert!(spec.baseline.as_function().is_none());
- assert_eq!(spec.schema.server.timeout, 30.0);
- assert_eq!(spec.schema.name, None);
- });
- }
-
- #[test]
- fn accepts_an_empty_flow_list() {
- with_spec(
- &format!("{VIEWPORTS}, {SERVER}, flows = {{}}"),
- |lua, table| {
- spec(lua, table).expect("valid config");
+ fn parses_a_setup_table() {
+ with_value(
+ r#"return {viewports = {{"desktop", 1280, 900}}, baseline = {repo = "kejadlen/ketchup"}}"#,
+ |lua, value| {
+ let setup: Setup = lua.from_value(value).expect("setup table");
+ assert_eq!(setup.viewports.len(), 1);
+ assert_eq!(setup.viewports[0].0.as_str(), "desktop");
+ assert_eq!(
+ setup.baseline.as_ref().expect("baseline").repo.as_str(),
+ "kejadlen/ketchup"
+ );
+ assert_eq!(setup.name, None);
},
);
}
#[test]
- fn reports_missing_fields() {
- assert_message(&format!("{SERVER}, {FLOWS}"), "missing field `viewports`");
- assert_message(&format!("{VIEWPORTS}, {FLOWS}"), "missing field `server`");
- assert_message(&format!("{VIEWPORTS}, {SERVER}"), "missing field `flows`");
- }
-
- #[test]
- fn reports_unknown_fields() {
- assert_message(
- &format!(r#"{VIEWPORTS}, {SERVER}, {FLOWS}, extra = 1"#),
- "unknown field",
- );
- }
-
- #[test]
- fn reports_type_and_shape_errors() {
- assert_message(
- &format!(r#"{VIEWPORTS}, server = {{cmd = {{"true", 2}}}}, {FLOWS}"#),
- "invalid type",
- );
- assert_message(
- &format!(r#"viewports = {{{{"desktop", 1280}}}}, {SERVER}, {FLOWS}"#),
- "invalid length",
- );
- assert_message(
- &format!(r#"viewports = {{{{"desktop", "wide", 900}}}}, {SERVER}, {FLOWS}"#),
- "invalid type",
- );
- assert_message(
- &format!(r#"viewports = {{{{"desktop", -1280, 900}}}}, {SERVER}, {FLOWS}"#),
- "invalid value",
- );
- }
-
- #[test]
- fn rejects_empty_or_unnamed_viewports() {
- assert_message(
- &format!("viewports = {{}}, {SERVER}, {FLOWS}"),
- "viewports must not be empty",
- );
- assert_message(
- &format!(r#"viewports = {{{{"desktop", 0, 900}}}}, {SERVER}, {FLOWS}"#),
- "invalid value",
- );
- assert_message(
- &format!(r#"viewports = {{{{"", 1280, 900}}}}, {SERVER}, {FLOWS}"#),
- "empty string",
- );
- }
-
- #[test]
- fn rejects_an_empty_server_cmd() {
- assert_message(
- &format!("{VIEWPORTS}, server = {{cmd = {{}}}}, {FLOWS}"),
- "server.cmd must not be empty",
- );
+ fn rejects_setup_shapes() {
+ let cases = [
+ (r#"return {baseline = nil}"#, "missing field `viewports`"),
+ (
+ r#"return {viewports = {{"desktop", 1280, 900}}, extra = 1}"#,
+ "unknown field",
+ ),
+ (r#"return {viewports = {{"", 1280, 900}}}"#, "empty string"),
+ (
+ r#"return {viewports = {{"desktop", 0, 900}}}"#,
+ "invalid value",
+ ),
+ (
+ r#"return {viewports = {{"desktop", 1280, 900}}, baseline = {asset = "x.tar.gz"}}"#,
+ "missing field `repo`",
+ ),
+ ];
+ for (body, fragment) in cases {
+ with_value(body, |lua, value| {
+ let error = lua
+ .from_value::<Setup>(value)
+ .expect_err("expected an invalid setup table");
+ assert!(
+ error.to_string().contains(fragment),
+ "expected {error:?} to contain {fragment:?}"
+ );
+ });
+ }
}
#[test]
- fn parses_a_function_baseline() {
- with_spec(
- &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = function() end"),
- |lua, table| {
- let spec = spec(lua, table).expect("valid config");
- assert!(spec.baseline.as_function().is_some());
+ fn rejects_functions_in_setup() {
+ with_value(
+ r#"return {viewports = {{"desktop", 1280, 900}}, name = (function() end)}"#,
+ |lua, value| {
+ let error = lua
+ .from_value::<Setup>(value)
+ .expect_err("functions cannot appear in setup");
+ assert!(error.to_string().contains("unsupported"));
},
);
- assert_message(
- &format!(r#"{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = "nope""#),
- ":baseline must be a function",
- );
}
#[test]
- fn rejects_non_table_flows() {
- assert_message(
- &format!(r#"{VIEWPORTS}, {SERVER}, flows = "none""#),
- ":flows must be a sequence of [name function] pairs",
- );
- }
-
- #[test]
- fn rejects_malformed_flow_pairs() {
- let cases = [
- r#"flows = {{"flow", "not-a-function"}}"#,
- r#"flows = {{"", function() end}}"#,
- r#"flows = {"flow"}"#,
- ];
- for case in cases {
- assert_message(
- &format!("{VIEWPORTS}, {SERVER}, {case}"),
- ":flows[1] must be a [name function] pair",
- );
- }
+ fn parses_a_server_table_with_defaults() {
+ with_value(r#"return {cmd = {"true"}}"#, |lua, value| {
+ let server: ServerSpec = lua.from_value(value).expect("server table");
+ assert_eq!(server.cmd, vec!["true".to_owned()]);
+ assert_eq!(server.timeout, 30.0);
+ });
}
#[test]
- fn keeps_explicit_timeout_and_name() {
- with_spec(
- &format!(
- r#"{VIEWPORTS}, server = {{cmd = {{"true"}}, timeout = 5}}, {FLOWS}, name = "ketchup""#
- ),
- |lua, table| {
- let spec = spec(lua, table).expect("valid config");
- assert_eq!(spec.schema.server.timeout, 5.0);
- assert_eq!(spec.schema.name.as_deref(), Some("ketchup"));
- },
- );
+ fn parses_a_server_table_with_a_timeout() {
+ with_value(r#"return {cmd = {"true"}, timeout = 5}"#, |lua, value| {
+ let server: ServerSpec = lua.from_value(value).expect("server table");
+ assert_eq!(server.timeout, 5.0);
+ });
}
}
diff --git a/tests/cli.rs b/tests/cli.rs
index 06daeff..fb7f3cc 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -6,17 +6,21 @@ use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use tempfile::TempDir;
-const VALID: &str = r#"(local parallax (require :parallax))
+const VALID: &str = r#"(local {: setup : flow : server} (require :parallax))
-(fn dashboard [] nil)
-(fn series-history [] nil)
-
-(parallax
+(setup
{:viewports [[:desktop 1280 900] [:mobile 375 812]]
- :server {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}
- :flows [[:dashboard dashboard]
- [:series-history series-history]]})
-"#;
+ :baseline {:repo "kejadlen/ketchup"}})
+
+(server
+ (fn []
+ {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}))
+
+(flow :dashboard
+ (fn [{: goto : wait-for : click : snap}] nil))
+
+(flow :series-history
+ (fn [{: goto : wait-for : app : snap}] nil))"#;
fn config_file(source: &str) -> (TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
@@ -75,18 +79,15 @@ fn syntax_error_points_at_the_config_line() {
}
#[test]
-fn validation_error_points_at_the_parallax_call() {
- let source = concat!(
- "(local parallax (require :parallax))\n",
+fn setup_errors_point_at_the_setup_call() {
+ let (_dir, path) = config_file(concat!(
+ "(local {: setup : flow : server} (require :parallax))\n",
"\n",
- "(fn flow [] nil)\n",
+ "(setup {:viewports []})\n",
"\n",
- "(parallax\n",
- " {:viewports []\n",
- " :server {:cmd [\"true\"]}\n",
- " :flows [[:flow flow]]})\n",
- );
- let (_dir, path) = config_file(source);
+ "(server (fn [] {:cmd [\"true\"]}))\n",
+ "(flow :flow (fn [] nil))\n",
+ ));
cargo_bin_cmd!("parallax")
.arg("list")
.arg("--config")
@@ -94,21 +95,79 @@ fn validation_error_points_at_the_parallax_call() {
.assert()
.failure()
.stderr(
- predicate::str::contains("config.fnl:5:")
- .and(predicate::str::contains("must not be empty")),
+ predicate::str::contains("config.fnl:3:")
+ .and(predicate::str::contains("viewports must not be empty")),
);
}
#[test]
-fn config_not_returning_a_parallax_call_fails() {
- let (_dir, path) = config_file("(local parallax (require :parallax))\n\n{:viewports []}\n");
+fn missing_setup_registration_fails() {
+ let (_dir, path) = config_file(concat!(
+ "(local {: setup : flow : server} (require :parallax))\n",
+ "(server (fn [] {:cmd [\"true\"]}))\n",
+ "(flow :flow (fn [] nil))\n",
+ ));
+ cargo_bin_cmd!("parallax")
+ .arg("list")
+ .arg("--config")
+ .arg(&path)
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("must call (setup ...)"));
+}
+
+#[test]
+fn missing_server_registration_fails() {
+ let (_dir, path) = config_file(concat!(
+ "(local {: setup : flow : server} (require :parallax))\n",
+ "(setup {:viewports [[:desktop 1280 900]]})\n",
+ "(flow :flow (fn [] nil))\n",
+ ));
cargo_bin_cmd!("parallax")
.arg("list")
.arg("--config")
.arg(&path)
.assert()
.failure()
- .stderr(predicate::str::contains("must return (parallax {...})"));
+ .stderr(predicate::str::contains("must call (server ...)"));
+}
+
+#[test]
+fn duplicate_setup_registration_fails() {
+ let (_dir, path) = config_file(concat!(
+ "(local {: setup : flow : server} (require :parallax))\n",
+ "(setup {:viewports [[:desktop 1280 900]]})\n",
+ "(setup {:viewports [[:mobile 375 812]]})\n",
+ "(server (fn [] {:cmd [\"true\"]}))\n",
+ ));
+ cargo_bin_cmd!("parallax")
+ .arg("list")
+ .arg("--config")
+ .arg(&path)
+ .assert()
+ .failure()
+ .stderr(
+ predicate::str::contains("config.fnl:3:").and(predicate::str::contains("exactly once")),
+ );
+}
+
+#[test]
+fn malformed_flow_registration_fails() {
+ let (_dir, path) = config_file(concat!(
+ "(local {: setup : flow : server} (require :parallax))\n",
+ "(setup {:viewports [[:desktop 1280 900]]})\n",
+ "(server (fn [] {:cmd [\"true\"]}))\n",
+ "(flow :flow \"not-a-function\")\n",
+ ));
+ cargo_bin_cmd!("parallax")
+ .arg("list")
+ .arg("--config")
+ .arg(&path)
+ .assert()
+ .failure()
+ .stderr(
+ predicate::str::contains("config.fnl:4:").and(predicate::str::contains("flow takes")),
+ );
}
#[test]