config: return the config table instead of registering it
Validating after the chunk returns trades line labels for key paths, and drops the registration module along with its duplicate-call, late-call, and missing-call cases.
Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/docs/plans/2026-09-25-parallax-design.md b/docs/plans/2026-09-25-parallax-design.md
index 9da077e..e2e22d2 100644
--- a/docs/plans/2026-09-25-parallax-design.md
+++ b/docs/plans/2026-09-25-parallax-design.md
@@ -73,7 +73,7 @@ Parallax has the following parts:
| CLI | Loads the config, runs variants, writes output, sets the exit code |
| Server supervisor | Spawns the app, waits for the handshake, tears down |
| Browser driver | Talks to Chrome over the DevTools Protocol |
-| Fennel runtime | Embeds Lua and the Fennel compiler, exposes the `parallax` module |
+| Fennel runtime | Embeds Lua and the Fennel compiler, evaluates the config |
| Comparator | Matches snapshots against the baseline and computes stats |
| GitHub client | Downloads snapshot archives from release assets |
| Site generator | Writes the static site and `report.json` |
@@ -92,7 +92,7 @@ For each variant, in sorted name order, parallax does the following:
1. Create a temporary directory and set `PARALLAX_HANDSHAKE` to a file
path inside it in the child's environment.
-2. Call the registered server function and spawn the returned command
+2. Call the config's server function and spawn the returned command
in its own process group, so that wrappers like `bundle exec` don't
leave orphaned children.
3. Poll for the handshake file until it exists and parses. If the child
@@ -138,46 +138,39 @@ avoids scanning output for a marker line.
## Config
-A config is a Fennel file that registers its pieces with the parallax
-module: `(setup {...})` with the static spec, `(server fn)` for the
-app under test, and `(flow fn)` for the single flow. Registration
-records without running anything; the CLI owns execution, so it can
-inspect a config without starting servers, for example to list
-variants.
+A config is a Fennel file that returns one table: the static spec,
+the app under test's start command, and the flow. Loading a config
+only evaluates it; the CLI owns execution, so it can inspect a config
+without starting servers, for example to list variants.
```fennel
-(local {: setup : flow : server} (require :parallax))
-
-(setup
- {:variants {:desktop-light {:viewport [1280 900] :scheme :light}
- :desktop-dark {:viewport [1280 900] :scheme :dark}
- :mobile-light {:viewport [375 812] :scheme :light}
- :mobile-dark {:viewport [375 812] :scheme :dark}}
- :baseline {:repo "kejadlen/ketchup"}})
-
-(server
- (fn []
- {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}))
-
-(flow
- (fn [{: goto : wait-for : click : snap : app : variant}]
- (goto "/")
- (wait-for ".dashboard")
- (snap :dashboard)
- (click ".complete-btn")
- (wait-for ".flash-bar")
- (snap :dashboard-after-complete)
- (goto (.. "/series/" (. (app) :series :mixed_history)))
- (wait-for ".task-history")
- (snap :series-history {:selector ".main-column"})
- (when (= (. variant :scheme) :dark)
- (click ".theme-toggle")
- (snap :series-history-dark {:selector ".main-column"}))))
+{:variants {:desktop-light {:viewport [1280 900] :scheme :light}
+ :desktop-dark {:viewport [1280 900] :scheme :dark}
+ :mobile-light {:viewport [375 812] :scheme :light}
+ :mobile-dark {:viewport [375 812] :scheme :dark}}
+ :baseline {:repo "kejadlen/ketchup"}
+ :server (fn []
+ {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
+ :flow (fn [{: goto : wait-for : click : snap : app : variant}]
+ (goto "/")
+ (wait-for ".dashboard")
+ (snap :dashboard)
+ (click ".complete-btn")
+ (wait-for ".flash-bar")
+ (snap :dashboard-after-complete)
+ (goto (.. "/series/" (. (app) :series :mixed_history)))
+ (wait-for ".task-history")
+ (snap :series-history {:selector ".main-column"})
+ (when (= (. variant :scheme) :dark)
+ (click ".theme-toggle")
+ (snap :series-history-dark {:selector ".main-column"})))}
```
-Registration calls can appear in any order. `setup`, `server`, and
-`flow` must each be called exactly once — a config that skips one
-gets an error saying so.
+Parallax validates the returned table after the config finishes
+running, so validation errors — an unknown field, a non-function
+`:server`, no variants — name the offending key path, such as
+`variants.desktop.shceme`, rather than a line. Syntax and runtime
+errors inside the config still report its line numbers.
A variant names a browser emulation: a viewport size paired with a
color scheme. The variant's name is its key in the `:variants` table,
@@ -186,9 +179,9 @@ tables don't preserve key order, and no variant depends on another's
state — each gets a fresh server — so variants run in sorted name
order, and the report and site inherit that grouping.
-The `setup` table and the table the `server` function returns are pure
-data, so both are validated where they're built and a bad table's
-error points into the config. Parallax calls the `server` function
+The `:server` and `:flow` functions are taken out of the table
+before the rest deserializes, so everything else is pure data,
+validated where it's built. Parallax calls the `:server` function
once per variant, before starting that variant's server, and spawns
the returned `:cmd` in its own process group. `:timeout` bounds the
handshake wait and defaults to 30 seconds.
@@ -205,10 +198,7 @@ baseline. Only GitHub releases are supported for now (see
A baseline directory is the output directory of an earlier run, so
archiving one run's output produces the next run's baseline.
-## Module API
-
-The module exposes three registration functions: `setup`, `server`,
-and `flow`.
+## Flow API
The flow takes one argument: the flow context, a table of command
functions Rust builds for the current variant, carrying its page, base
@@ -357,7 +347,7 @@ subject to GitHub's lower anonymous rate limit.
### Reading
-The `:baseline` field of `setup` names the source:
+The `:baseline` field of the config table names the source:
| Field | Meaning |
|---|---|
diff --git a/src/error.rs b/src/error.rs
index 3330ab9..da4c52a 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,8 +1,6 @@
use camino::Utf8PathBuf;
use miette::Diagnostic;
-use miette::NamedSource;
-use miette::SourceSpan;
#[derive(Debug, thiserror::Error, Diagnostic)]
pub enum Error {
@@ -13,22 +11,12 @@ pub enum Error {
)]
Config(#[from] mlua::Error),
- #[error("{message}")]
- #[diagnostic(code(parallax::config_invalid))]
- ConfigInvalid {
- message: String,
- #[source_code]
- source_code: NamedSource<String>,
- #[label("here")]
- span: SourceSpan,
- },
-
- #[error("{path} must call (parallax ...)")]
+ #[error("{path}: {message}")]
#[diagnostic(
- code(parallax::config_registration),
- help("a config registers with (parallax {{...}})")
+ code(parallax::config_invalid),
+ help("a config returns {{:variants {{...}} :server (fn ...) :flow (fn ...)}}")
)]
- ConfigRegistration { path: Utf8PathBuf },
+ ConfigInvalid { path: Utf8PathBuf, message: String },
#[error("io error: {0}")]
#[diagnostic(code(parallax::io))]
diff --git a/src/parallax.rs b/src/parallax.rs
index b70a77e..8c14e1f 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -1,16 +1,7 @@
use camino::Utf8Path;
use camino::Utf8PathBuf;
-use std::fmt;
-use std::mem;
-use std::sync::Arc;
-use std::sync::Mutex;
-use std::sync::MutexGuard;
-use std::sync::PoisonError;
-use miette::NamedSource;
-use miette::SourceSpan;
use mlua::ErrorContext as _;
-use mlua::FromLua;
use mlua::Function;
use mlua::Lua;
use mlua::Table;
@@ -23,37 +14,41 @@ use crate::spec::Variant;
const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
-/// The table passed to `(parallax {...})`: the setup spec plus the
-/// registered server and flow functions. Converted inside the
-/// registration callback, so conversion errors carry the call site.
+/// The table a config returns: the setup spec plus the server and flow
+/// functions.
struct Config {
server: Function,
flow: Function,
setup: Setup,
}
-impl FromLua for Config {
- fn from_lua(value: Value, lua: &Lua) -> Result<Config, mlua::Error> {
+impl Config {
+ /// Converts after the config's chunk has returned, so there's no Lua
+ /// position to report; the key path locates mistakes instead.
+ fn from_value(path: &Utf8Path, value: Value) -> Result<Config, Error> {
+ let invalid = |message: String| Error::ConfigInvalid {
+ path: path.to_owned(),
+ message,
+ };
let Value::Table(table) = value else {
- return Err(config_error(lua, "parallax takes a table"));
+ return Err(invalid(format!(
+ "the config must return a table, not {}",
+ value.type_name()
+ )));
};
- // Popping from a copy leaves the config's own table intact.
- let spec = shallow_copy(lua, &table)?;
- let server = pop_function(&spec, "server", lua)?;
- let flow = pop_function(&spec, "flow", lua)?;
- // The whole call usually sits on one line, so the key path is
- // what locates a mistake inside the table.
- let setup: Setup = serde_path_to_error::deserialize(Deserializer::new(Value::Table(spec)))
+ let server = pop_function(&table, "server")?
+ .ok_or_else(|| invalid("server must be a function".to_owned()))?;
+ let flow = pop_function(&table, "flow")?
+ .ok_or_else(|| invalid("flow must be a function".to_owned()))?;
+ let setup: Setup = serde_path_to_error::deserialize(Deserializer::new(Value::Table(table)))
.map_err(|error| {
- let path = error.path().to_string();
+ let key_path = error.path().to_string();
match error.into_inner() {
- mlua::Error::DeserializeError(message) if path == "." => {
- config_error(lua, message)
- }
+ mlua::Error::DeserializeError(message) if key_path == "." => invalid(message),
mlua::Error::DeserializeError(message) => {
- config_error(lua, format!("{path}: {message}"))
+ invalid(format!("{key_path}: {message}"))
}
- other => other,
+ other => Error::Config(other),
}
})?;
Ok(Config {
@@ -64,23 +59,15 @@ impl FromLua for Config {
}
}
-/// Takes `key` out of the table as a function, so the serde pass
-/// never sees it and unknown-field validation still catches typos.
-fn pop_function(table: &Table, key: &str, lua: &Lua) -> Result<Function, mlua::Error> {
- let Value::Function(call) = table.raw_get::<Value>(key)? else {
- return Err(config_error(lua, format!("{key} must be a function")));
- };
+/// Takes `key` out of the table, so the serde pass never sees it and
+/// unknown-field validation still catches typos.
+fn pop_function(table: &Table, key: &str) -> Result<Option<Function>, mlua::Error> {
+ let value = table.raw_get::<Value>(key)?;
table.raw_set(key, Value::Nil)?;
- Ok(call)
-}
-
-fn shallow_copy(lua: &Lua, table: &Table) -> Result<Table, mlua::Error> {
- let copy = lua.create_table()?;
- for pair in table.pairs::<Value, Value>() {
- let (key, value) = pair?;
- copy.raw_set(key, value)?;
- }
- Ok(copy)
+ Ok(match value {
+ Value::Function(call) => Some(call),
+ _ => None,
+ })
}
/// A loaded parallax: the executable form of a config. The server
@@ -109,13 +96,10 @@ 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 filename = path.as_str().to_owned();
- let config = run_config(&lua, &source, &filename)
- .map_err(|error| load_error(path, &source, error))?
- .ok_or_else(|| Error::ConfigRegistration {
- path: path.to_owned(),
- })?;
+ let value =
+ run_config(&lua, &source, path.as_str()).with_context(|_| format!("loading {path}"))?;
+ let config = Config::from_value(path, value)?;
let name = config
.setup
.name
@@ -154,200 +138,19 @@ fn default_name(path: &Utf8Path) -> String {
.to_owned()
}
-/// Where `(parallax ...)` leaves its config. Closed once loading ends,
-/// so a call from inside a flow fails instead of registering into a
-/// slot nobody reads.
-enum Slot {
- Empty,
- Filled(Config),
- Closed,
-}
-
-// Poisoning only follows a panic mid-store; the slot is still
-// coherent, so take it rather than fail.
-fn lock(slot: &Mutex<Slot>) -> MutexGuard<'_, Slot> {
- slot.lock().unwrap_or_else(PoisonError::into_inner)
-}
-
-/// Builds the `parallax` module in package.preload — a single function
-/// that converts its argument to a Config and stores it in the slot
-/// the caller keeps — and returns that slot. The function raises
-/// errors while the config's stack frame is alive, so failures point
-/// at the call site.
-fn install_module(lua: &Lua) -> Result<Arc<Mutex<Slot>>, mlua::Error> {
- let slot = Arc::new(Mutex::new(Slot::Empty));
- let parallax = {
- let slot = slot.clone();
- // Takes a Value and converts it after checking the slot, so a
- // late call reports that rather than a problem with its table.
- lua.create_function(move |lua, value: Value| {
- match *lock(&slot) {
- Slot::Empty => {}
- Slot::Filled(_) => {
- return Err(config_error(lua, "parallax must be called exactly once"));
- }
- Slot::Closed => {
- return Err(config_error(
- lua,
- "parallax can only be called while the config loads",
- ));
- }
- }
- let config = Config::from_lua(value, lua)?;
- *lock(&slot) = Slot::Filled(config);
- Ok(())
- })?
- };
-
- let lookup = lua.create_function(move |_, _name: String| Ok(parallax.clone()))?;
- let package: Table = lua.globals().get("package")?;
- let preload: Table = package.get("preload")?;
- preload.set("parallax", lookup)?;
- Ok(slot)
-}
-
-/// A config mistake caught by a Rust callback. It travels through Lua
-/// as an external error, and `load_error` downcasts it to label the
-/// config's source.
-#[derive(Debug)]
-struct ConfigError {
- caller: Option<Caller>,
- message: String,
-}
-
-/// The Lua position that called into Rust.
-#[derive(Debug)]
-struct Caller {
- file: String,
- line: usize,
-}
-
-impl fmt::Display for ConfigError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- if let Some(Caller { file, line }) = &self.caller {
- write!(f, "{file}:{line}: ")?;
- }
- f.write_str(&self.message)
- }
-}
-
-impl std::error::Error for ConfigError {}
-
-fn config_error(lua: &Lua, message: impl Into<String>) -> mlua::Error {
- mlua::Error::external(ConfigError {
- caller: caller(lua),
- message: message.into(),
- })
-}
-
-/// Level 0 is the running Rust callback, so level 1 is its caller —
-/// what `error(msg, 2)` reports in plain Lua. Calling a C function
-/// from tail position keeps the caller's frame, so this holds even for
-/// a config's final form.
-fn caller(lua: &Lua) -> Option<Caller> {
- lua.inspect_stack(1, |debug| {
- let file = debug.source().source?.strip_prefix('@')?.to_owned();
- let line = debug.current_line()?;
- Some(Caller { file, line })
- })?
-}
-
-/// Labels the config's source when the error came from a call in the
-/// config file itself; anything else stays an mlua error.
-fn load_error(path: &Utf8Path, source: &str, error: mlua::Error) -> Error {
- if let Some(ConfigError {
- caller: Some(caller),
- message,
- }) = error.downcast_ref::<ConfigError>()
- && caller.file == path.as_str()
- && let Some(span) = line_span(source, caller.line)
- {
- return Error::ConfigInvalid {
- message: message.clone(),
- source_code: NamedSource::new(path.as_str(), source.to_owned()),
- span,
- };
- }
- Error::Config(error.context(format!("loading {path}")))
-}
-
-/// The span of a 1-based line, without its indentation or line ending.
-fn line_span(source: &str, line: usize) -> Option<SourceSpan> {
- let mut offset: usize = 0;
- for (number, text) in (1..).zip(source.split_inclusive('\n')) {
- if number == line {
- let trimmed = text.trim();
- let indent = text.len().checked_sub(text.trim_start().len())?;
- return Some((offset.checked_add(indent)?, trimmed.len()).into());
- }
- offset = offset.checked_add(text.len())?;
- }
- None
-}
-
-/// Loads the Fennel compiler, runs the config, and returns the config
-/// it registered — None when the config never called `(parallax ...)`.
-/// Every failure surfaces as an mlua error; the caller annotates it
-/// with the config's path.
-fn run_config(lua: &Lua, source: &str, filename: &str) -> Result<Option<Config>, mlua::Error> {
+/// Loads the Fennel compiler, runs the config, and returns whatever
+/// its chunk returns.
+fn run_config(lua: &Lua, source: &str, filename: &str) -> Result<Value, mlua::Error> {
let fennel: Table = lua.load(FENNEL_LUA).set_name("fennel.lua").eval()?;
- let slot = install_module(lua)?;
// `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.
+ // so syntax and runtime errors report the config's line numbers; a
+ // form that emits several statements 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))?;
- lua.load(compiled)
- .set_name(format!("@{filename}"))
- .eval::<()>()?;
-
- match mem::replace(&mut *lock(&slot), Slot::Closed) {
- Slot::Filled(config) => Ok(Some(config)),
- Slot::Empty | Slot::Closed => Ok(None),
- }
-}
-
-#[cfg(test)]
-#[allow(clippy::expect_used)]
-mod tests {
- use super::*;
-
- #[test]
- fn spans_a_line_without_indentation_or_newline() {
- let source = "(first)\n (second)\r\n(third)";
- assert_eq!(line_span(source, 1), Some((0, 7).into()));
- assert_eq!(line_span(source, 2), Some((10, 8).into()));
- assert_eq!(line_span(source, 3), Some((20, 7).into()));
- assert_eq!(line_span(source, 4), None);
- assert_eq!(line_span(source, 0), None);
- }
-
- #[test]
- fn rejects_registration_after_loading() {
- let lua = unsafe { Lua::unsafe_new() };
- let source = concat!(
- "(local parallax (require :parallax))\n",
- "(fn late [] (parallax {:variants {:x {:viewport [1 1]}}",
- " :server (fn [] nil) :flow (fn [] nil)}))\n",
- "(parallax {:variants {:d {:viewport [1 1]}} :server (fn [] nil) :flow late})\n",
- );
- let config = run_config(&lua, source, "config.fnl")
- .expect("config loads")
- .expect("config registers");
- let error = config
- .flow
- .call::<()>(())
- .expect_err("registering after loading must fail");
- assert!(
- error
- .to_string()
- .contains("only be called while the config loads"),
- "unexpected error: {error}"
- );
- }
+ lua.load(compiled).set_name(format!("@{filename}")).eval()
}
diff --git a/src/spec.rs b/src/spec.rs
index 955dea4..d881555 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -56,7 +56,7 @@ pub struct Variant {
pub scheme: Scheme,
}
-/// The data half of the table passed to `(parallax {...})`, with
+/// The data half of the table a config returns, with
/// variants flattened into run order. The caller pulls `server` and
/// `flow` out first; a function anywhere else is an error, which is why
/// it deserializes with mlua's default strict options.
@@ -70,7 +70,7 @@ pub(crate) struct Setup {
pub(crate) name: Option<String>,
}
-/// What `(parallax {...})` deserializes from: variants keyed by name,
+/// What a config's table deserializes from: variants keyed by name,
/// so the names are data rather than struct fields.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
diff --git a/tests/cli.rs b/tests/cli.rs
index 8131d68..387d93c 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -6,15 +6,12 @@ use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use tempfile::TempDir;
-const VALID: &str = r#"(local parallax (require :parallax))
-
-(parallax
- {:variants {:mobile-dark {:viewport [375 812] :scheme :dark}
- :desktop-light {:viewport [1280 900]}
- :desktop-dark {:viewport [1280 900] :scheme :dark}}
- :baseline {:repo "kejadlen/ketchup"}
- :server (fn [] {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
- :flow (fn [{: goto : wait-for : click : snap : variant}] nil)})"#;
+const VALID: &str = r#"{:variants {:mobile-dark {:viewport [375 812] :scheme :dark}
+ :desktop-light {:viewport [1280 900]}
+ :desktop-dark {:viewport [1280 900] :scheme :dark}}
+ :baseline {:repo "kejadlen/ketchup"}
+ :server (fn [] {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
+ :flow (fn [{: goto : wait-for : click : snap : variant}] nil)}"#;
fn config_file(source: &str) -> (TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
@@ -23,6 +20,21 @@ fn config_file(source: &str) -> (TempDir, PathBuf) {
(dir, path)
}
+fn list_fails_with(source: &str, fragments: &[&str]) {
+ let (_dir, path) = config_file(source);
+ let mut stderr = predicate::str::contains("config.fnl").boxed();
+ for fragment in fragments {
+ stderr = stderr.and(predicate::str::contains(*fragment)).boxed();
+ }
+ cargo_bin_cmd!("parallax")
+ .arg("list")
+ .arg("--config")
+ .arg(&path)
+ .assert()
+ .failure()
+ .stderr(stderr);
+}
+
#[test]
fn lists_variant_names_in_run_order() {
let (_dir, path) = config_file(VALID);
@@ -60,157 +72,63 @@ fn missing_config_file_fails() {
#[test]
fn syntax_error_points_at_the_config_line() {
- let (_dir, path) = config_file("(local ok 1)\n)\n");
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(
- predicate::str::contains("config.fnl:2:").and(predicate::str::contains("Parse error")),
- );
+ list_fails_with("(local ok 1)\n)\n", &["config.fnl:2:", "Parse error"]);
}
#[test]
-fn empty_variants_error_points_at_the_parallax_call() {
- let (_dir, path) = config_file(concat!(
- "(local parallax (require :parallax))\n",
- "(parallax {:variants {} :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
- ));
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(predicate::str::contains("config.fnl:2:").and(predicate::str::contains("empty")));
+fn empty_variants_fail() {
+ list_fails_with(
+ "{:variants {} :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)}\n",
+ &["variants must not be empty"],
+ );
}
#[test]
-fn unknown_field_errors_point_at_the_parallax_call() {
- let (_dir, path) = config_file(concat!(
- "(local parallax (require :parallax))\n",
- "(parallax {:variants {:desktop {:viewport [1280 900]}} :extra 1\n",
- " :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
- ));
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(
- predicate::str::contains("config.fnl:3:")
- .and(predicate::str::contains("unknown"))
- .and(predicate::str::contains("`server`, `flow`"))
- .and(predicate::str::contains("bad argument").not()),
- );
+fn unknown_fields_fail() {
+ list_fails_with(
+ concat!(
+ "{:variants {:desktop {:viewport [1280 900]}} :extra 1\n",
+ " :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)}\n",
+ ),
+ &["unknown field `extra`", "`server`, `flow`"],
+ );
}
#[test]
fn nested_errors_name_the_key_path() {
- let (_dir, path) = config_file(concat!(
- "(local parallax (require :parallax))\n",
- "(parallax {:variants {:desktop {:viewport [1280 900] :shceme :dark}}\n",
- " :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
- ));
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(predicate::str::contains("variants.desktop.shceme:"));
+ list_fails_with(
+ concat!(
+ "{:variants {:desktop {:viewport [1280 900] :shceme :dark}}\n",
+ " :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)}\n",
+ ),
+ &["variants.desktop.shceme:"],
+ );
}
#[test]
-fn leaves_the_config_table_intact() {
- let (_dir, path) = config_file(concat!(
- "(local parallax (require :parallax))\n",
- "(local config {:variants {:desktop {:viewport [1280 900]}}\n",
- " :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
- "(parallax config)\n",
- "(assert config.server \"server was removed from the config table\")\n",
- ));
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .success();
+fn non_table_config_fails() {
+ list_fails_with("\"nope\"\n", &["must return a table, not string"]);
}
#[test]
-fn non_table_argument_fails() {
- let (_dir, path) = config_file("(local parallax (require :parallax))\n(parallax \"nope\")\n");
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(predicate::str::contains("config.fnl:2:").and(predicate::str::contains("takes")));
+fn config_that_returns_nothing_fails() {
+ list_fails_with("(local ok 1)\n", &["must return a table, not nil"]);
}
#[test]
fn server_must_be_a_function() {
- let (_dir, path) = config_file(concat!(
- "(local parallax (require :parallax))\n",
- "(parallax {:variants {:desktop {:viewport [1280 900]}} :server \"not-a-function\"\n",
- " :flow (fn [] nil)})\n",
- ));
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(predicate::str::contains("config.fnl:3:").and(predicate::str::contains("server")));
+ list_fails_with(
+ "{:variants {:desktop {:viewport [1280 900]}} :server \"nope\" :flow (fn [] nil)}\n",
+ &["server must be a function"],
+ );
}
#[test]
fn flow_must_be_a_function() {
- let (_dir, path) = config_file(concat!(
- "(local parallax (require :parallax))\n",
- "(parallax {:variants {:desktop {:viewport [1280 900]}}\n",
- " :server (fn [] {:cmd [\"true\"]}) :flow \"not-a-function\"})\n",
- ));
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(predicate::str::contains("config.fnl:3:").and(predicate::str::contains("flow")));
-}
-
-#[test]
-fn missing_parallax_call_fails() {
- let (_dir, path) = config_file("(local parallax (require :parallax))\n");
- cargo_bin_cmd!("parallax")
- .arg("list")
- .arg("--config")
- .arg(&path)
- .assert()
- .failure()
- .stderr(predicate::str::contains("must call (parallax ...)"));
-}
-
-#[test]
-fn duplicate_parallax_call_fails() {
- let (_dir, path) = config_file(concat!(
- "(local parallax (require :parallax))\n",
- "(parallax {:variants {:desktop {:viewport [1280 900]}} :server (fn [] nil) :flow (fn [] nil)})\n",
- "(parallax {:variants {:desktop {:viewport [1280 900]}} :server (fn [] nil) :flow (fn [] nil)})\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")));
+ list_fails_with(
+ "{:variants {:desktop {:viewport [1280 900]}} :server (fn [] nil) :flow \"nope\"}\n",
+ &["flow must be a function"],
+ );
}
#[test]