//! Generates an app and scaffolds with the real `glacier` binary, then
//! builds the result with plain cargo, runs its generated tests, and runs
//! the app, so the generators can't drift from the framework without a
//! failing test.
//!
//! Controllers default to the .glacier dialect, compiled by the app's
//! build.rs; one scaffold uses `--dialect rust` to cover plain Rust too.
use std::path::{Path, PathBuf};
use std::process::Command;
fn glacier(dir: &Path, args: &[&str]) -> String {
let out = Command::new(env!("CARGO_BIN_EXE_glacier"))
.args(args)
.current_dir(dir)
.output()
.expect("run glacier");
assert!(
out.status.success(),
"glacier {args:?} failed:\n{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
/// The workspace's target dir, so the app reuses already-built crates.
fn target_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/generated-apps")
}
fn cargo(app: &Path, args: &[&str]) -> String {
let out = Command::new("cargo")
.args(args)
.args(["--offline", "--quiet"])
.current_dir(app)
.env("CARGO_TARGET_DIR", target_dir())
.output()
.expect("run cargo");
let output = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(out.status.success(), "cargo {args:?} failed:\n{output}");
assert!(
!output.contains("warning"),
"cargo {args:?} has warnings:\n{output}"
);
output
}
#[test]
fn new_app_with_scaffolds_builds_tests_and_runs() {
let root = std::env::temp_dir().join(format!("glacier-gen-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
glacier(&root, &["new", "shop"]);
let app = root.join("shop");
let controllers = app.join("src/controllers");
// The starter controller is .glacier by default.
assert!(controllers.join("pages_controller.glacier").exists());
assert!(!controllers.join("pages_controller.rs").exists());
// One scaffold in plain Rust, two in .glacier (the default).
glacier(
&app,
&[
"generate",
"scaffold",
"Product",
"name:string",
"description:text:optional",
"price:float",
"stock:integer",
"featured:boolean",
"--dialect",
"rust",
],
);
assert!(controllers.join("products_controller.rs").exists());
// Scopes on a model, chained from .glacier below.
let product = app.join("src/models/product.rs");
let model = std::fs::read_to_string(&product).unwrap();
std::fs::write(
&product,
format!(
"{model}\n#[glacier::scopes]\nimpl Product {{\n fn cheap(products: glacier::db::Query<Self>) -> glacier::db::Query<Self> {{\n products.filter(|p| p.price.lt(10.0))\n }}\n\n fn named(products: glacier::db::Query<Self>, name: &str) -> glacier::db::Query<Self> {{\n let name = name.to_owned();\n products.filter(|p| p.name.eq(name))\n }}\n}}\n"
),
)
.unwrap();
glacier(&app, &["g", "scaffold", "Comment", "body:text"]);
// A foreign key: its tests compile but wait for parent records.
glacier(
&app,
&["g", "scaffold", "Review", "product:references", "body:text"],
);
// Generators for a model or a controller alone write their tests too.
glacier(
&app,
&[
"g",
"model",
"Tag",
"name:string",
"slug:string",
"kind:string",
],
);
// A .glacier model using every kind of member, replacing the generated one.
std::fs::write(
app.join("src/models/tag.glacier"),
r##"model Tag do
field name: String, required, length(min: 2, max: 30)
field slug: String
field kind: enum(topic, language), default: :topic
scope named(name: &str), where(name: name)
scope languages, where(kind: :language)
def label(self) -> String do
format!("#{}", self.name)
end
def rename(self, name: &str) do
self.update(name: name)!
end
def language_count() -> i64 do
Tag.languages().count()!
end
before_save do
self.slug = self.name.to_lowercase()
end
end
"##,
)
.unwrap();
std::fs::write(
app.join("tests/factories/tag.glacier"),
"factory Tag do\n name: \"Example name\"\n slug: \"\"\n kind: \"topic\"\nend\n",
)
.unwrap();
let tag_tests = std::fs::read_to_string(app.join("tests/models/tag.test.glacier")).unwrap();
std::fs::write(
app.join("tests/models/tag.test.glacier"),
format!(
"{tag_tests}\ntest \"model features\" do\n tag = Tag.new(name: \"Rust\", kind: :language)\n assert tag.save()!\n assert tag.slug == \"rust\"\n assert tag.label() == \"#Rust\"\n assert tag.kind.is_language()\n assert Tag.languages().count()! == 1\n assert Tag.language_count()! == 1\n assert Tag.named(\"Rust\").exists()!\n tag.rename(\"Elixir\")!\n assert Tag.find(tag.id)!.slug == \"elixir\"\n short = Tag.new(name: \"x\")\n refute short.save()!\n assert short.errors_on(\"name\") == [\"is too short (minimum is 2 characters)\"]\nend\n"
),
)
.unwrap();
glacier(&app, &["g", "controller", "Reports", "summary"]);
glacier(
&app,
&["g", "model", "Note", "body:text", "--dialect", "rust"],
);
glacier(
&app,
&["g", "controller", "Legal", "terms", "--dialect", "rust"],
);
for file in [
"tests/models/tag.test.glacier",
"tests/factories/tag.glacier",
"tests/controllers/reports_controller.test.glacier",
"tests/models/comment.test.glacier",
"tests/note_model.rs",
"tests/legal_controller.rs",
"tests/product_model.rs",
] {
assert!(app.join(file).exists(), "{file}");
}
assert!(controllers.join("comments_controller.glacier").exists());
assert!(
!controllers.join("comments_controller.rs").exists(),
"a .glacier scaffold writes only its source"
);
let mods = std::fs::read_to_string(controllers.join("mod.rs")).unwrap();
assert!(
mods.contains("glacier::controllers!(pages, comments, reviews, reports);"),
"{mods}"
);
let routes = std::fs::read_to_string(app.join("src/routes.rs")).unwrap();
let home = routes.find("get \"/\"").unwrap();
let products = routes.find("resources \"/products\" => Products;").unwrap();
assert!(
products > home,
"new resources go at the end of the scope:\n{routes}"
);
// A hand-written .glacier controller using multi-line arms and closures,
// cond, with, and blocks assigned to names.
std::fs::write(
controllers.join("stats_controller.glacier"),
r#"controller Stats do
def index do
case Product.count()! do
0 ->
Ok(Response.text("none"))
n ->
cheap = Product.filter(fn p ->
p.price.lt(10.0)
end)!
size = cond do
n > 100 -> "many"
n > 1 -> "some"
true -> "one"
end
first = with Some(p) <- cheap.first(),
true <- p.stock > 0 do
p.name.clone()
else
"none in stock".to_owned()
end
Ok(Response.text(format!("{n} products ({size}), {} cheap: {first}", cheap.len())))
end
end
end
"#,
)
.unwrap();
// Generated and hand-written .glacier files are already in canonical form.
glacier(&app, &["fmt", "--check"]);
let mods = std::fs::read_to_string(controllers.join("mod.rs")).unwrap();
std::fs::write(
controllers.join("mod.rs"),
mods.replace("reports);", "reports, stats);"),
)
.unwrap();
let routes = std::fs::read_to_string(app.join("src/routes.rs")).unwrap();
std::fs::write(
app.join("src/routes.rs"),
routes.replace(
"get \"/\" => Pages::home, as home;",
"get \"/\" => Pages::home, as home;\n get \"/stats\" => Stats::index, as stats;",
),
)
.unwrap();
// A hand-written .glacier test file, and a factory for the Rust
// scaffold's model.
std::fs::write(
app.join("tests/controllers/stats_controller.test.glacier"),
r#"# Calls to helpers are awaited for you.
def stats -> String do
get("/stats").body
end
describe "/stats" do
test "counts products" do
assert stats() == "none"
insert(Product, price: 5.0)
insert(Product, name: "Pricey", price: 50.0)
assert Product.count()! == 2
assert stats() == "2 products (some), 1 cheap: Lamp"
refute stats() =~ "none"
end
test "posts keyword lists" do
get "/products/new"
res = post "/products", [name: "Lamp", price: 3.5, stock: 2]
assert res.status == :see_other
assert get(res.location).body =~ "Lamp"
end
test "waits for later", skip: "shows how to skip" do
assert false
end
end
test "active record keywords" do
insert(Product, name: "Lamp", price: 5.0)
insert(Product, name: "Desk", price: 50.0, description: "Oak")
assert Product.cheap().count()! == 1
assert Product.named("Desk").cheap().count()! == 0
assert Product.where(price: 5.0).cheap().count()! == 1
assert Product.where(name: "Lamp").count()! == 1
assert Product.where_not(description: nil).count()! == 1
assert Product.exists(name: "Desk")!
assert Product.all().order(name: :asc).pluck(:name)! == ["Desk", "Lamp"]
lamp = Product.find_by(name: "Lamp")!.unwrap()
lamp.price = 7.5
lamp.description = "Brass"
assert lamp.changed("price")
assert lamp.save()!
lamp.update(stock: 3)!
assert Product.find(lamp.id)!.stock == 3
assert Product.where(stock: 3).update_all(stock: 0)! == 1
chair = Product.new(name: "Chair", price: 20.0)
assert chair.save()!
assert Product.count()! == 3
end
"#,
)
.unwrap();
std::fs::write(
app.join("tests/factories/product.glacier"),
"factory Product do\n name: \"Lamp\"\n price: 9.5\n stock: 1\nend\n",
)
.unwrap();
let suite = std::fs::read_to_string(app.join("tests/glacier.rs")).unwrap();
assert!(
suite.contains(
"glacier::tests!(controllers::comments_controller, controllers::pages_controller, \
controllers::reports_controller, controllers::reviews_controller, \
factories::comment, factories::review, factories::tag, \
models::comment, models::review, models::tag);"
),
"{suite}"
);
std::fs::write(
app.join("tests/glacier.rs"),
suite.replace(
"models::tag);",
"models::tag, controllers::stats_controller, factories::product);",
),
)
.unwrap();
glacier(&app, &["fmt", "--check"]);
// Plain cargo: build.rs compiles the .glacier files first.
cargo(&app, &["build"]);
assert!(
app.join(".glacier/controllers/comments_controller.rs")
.exists(),
"build.rs compiled it into the hidden build directory"
);
let source_files: Vec<String> = std::fs::read_dir(&controllers)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(
source_files.iter().all(|f| f.ends_with(".glacier")
|| ["products_controller.rs", "legal_controller.rs", "mod.rs"].contains(&f.as_str())),
"src/ holds only what you write: {source_files:?}"
);
let ignore = std::fs::read_to_string(app.join(".gitignore")).unwrap();
assert!(
ignore.contains("/.glacier/"),
"compiled output is ignored by git"
);
// Every scaffold's generated tests pass, whatever its dialect.
let tests = cargo(&app, &["test"]);
assert!(
tests.contains("5 passed; 0 failed; 0 ignored"),
"products' Rust tests pass:\n{tests}"
);
// Controllers: pages 1, comments 5, reviews 1 (4 wait for a Product
// factory that didn't exist yet), reports 1, stats 2 (1 skipped).
// Models: comment 2, review 1 (1 waits), tag 2. Plus the Active Record
// keywords test. All in one binary.
assert!(
tests.contains("17 passed; 0 failed; 6 ignored"),
".glacier tests pass:\n{tests}"
);
// A failing assertion explains itself and points at its .glacier line.
std::fs::write(
app.join("tests/failing.test.glacier"),
"test \"fails\" do\n res = get \"/\"\n assert res.status == :not_found\nend\n",
)
.unwrap();
std::fs::write(
app.join("tests/glacier.rs"),
suite.replace(
"models::tag);",
"models::tag, controllers::stats_controller, factories::product, failing);",
),
)
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_glacier"))
.args(["test", "--offline", "--", "fails"])
.current_dir(&app)
.env("CARGO_TARGET_DIR", target_dir())
.output()
.expect("run glacier test");
let printed = String::from_utf8_lossy(&out.stdout);
assert!(!out.status.success(), "{printed}");
for expected in [
"tests/failing.test.glacier:3:",
"assertion failed: res.status == :not_found",
"left: :ok (200)",
] {
assert!(
printed.contains(expected),
"missing {expected:?}:\n{printed}"
);
}
std::fs::remove_file(app.join("tests/failing.test.glacier")).unwrap();
let run = |task: &str| {
let out = Command::new(target_dir().join("debug/shop"))
.current_dir(&app)
.env("GLACIER_ENV", "test")
.env("GLACIER_TASK", task)
.output()
.expect("run app");
assert!(
out.status.success(),
"{task}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
};
let migrated = run("db:migrate");
for table in ["products", "comments", "reviews"] {
assert!(migrated.contains(&format!("_create_{table}")), "{migrated}");
}
let table = run("routes");
for row in [
"Pages#home",
"Products#edit",
"Comments#index",
"Comments#update",
"Reviews#destroy",
] {
assert!(table.contains(row), "missing {row} in:\n{table}");
}
std::fs::remove_dir_all(&root).unwrap();
}
/// Adds a scope to the end of the app's routes! table.
fn add_scope(app: &Path, scope: &str) {
let path = app.join("src/routes.rs");
let routes = std::fs::read_to_string(&path).unwrap();
let close = routes.rfind("\n}").unwrap() + 1;
let updated = format!("{}{scope}{}", &routes[..close], &routes[close..]);
std::fs::write(path, updated).unwrap();
}
#[test]
fn authentication_for_pages_and_apis() {
let root = std::env::temp_dir().join(format!("glacier-auth-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
// Pages: sign up, in and out, and a page only for signed-in users.
glacier(&root, &["new", "accounts"]);
let app = root.join("accounts");
let out = glacier(&app, &["generate", "authentication"]);
assert!(out.contains("glacier db migrate"), "{out}");
std::fs::write(
app.join("src/controllers/accounts_controller.glacier"),
"alias Authentication.current_user\n\ncontroller Accounts do\n def show(ctx) do\n email_address = current_user(&ctx).map(fn u -> u.email_address.clone() end).unwrap_or_default()\n render email_address: email_address, title: \"Account\"\n end\nend\n",
)
.unwrap();
std::fs::create_dir_all(app.join("src/views/accounts")).unwrap();
std::fs::write(
app.join("src/views/accounts/show.html"),
"{% props email_address: String %}\n<h1>Signed in as {{ email_address }}</h1>\n",
)
.unwrap();
let mods = std::fs::read_to_string(app.join("src/controllers/mod.rs")).unwrap();
std::fs::write(
app.join("src/controllers/mod.rs"),
mods.replace("glacier::controllers!(", "glacier::controllers!(accounts, "),
)
.unwrap();
add_scope(
&app,
"\n scope \"/\" through browser, authenticated {\n get \"/account\" => Accounts::show, as account;\n }\n",
);
std::fs::write(
app.join("tests/controllers/accounts_controller.test.glacier"),
"test \"sends visitors to sign in, then back\" do\n insert(User, email_address: \"user@example.com\")\n res = get \"/account\"\n assert res.location == \"/session/new\"\n get \"/session/new\"\n res = post \"/session\", [email_address: \"user@example.com\", password: \"password\"]\n assert res.location == \"/account\"\n assert get(\"/account\").body =~ \"Signed in as user@example.com\"\nend\n",
)
.unwrap();
let suite = std::fs::read_to_string(app.join("tests/glacier.rs")).unwrap();
std::fs::write(
app.join("tests/glacier.rs"),
suite.replace(
"glacier::tests!(",
"glacier::tests!(controllers::accounts_controller, ",
),
)
.unwrap();
glacier(&app, &["fmt", "--check"]);
let tests = cargo(&app, &["test"]);
// pages 1, sessions 3, registrations 2, user 4, accounts 1.
assert!(
tests.contains("11 passed; 0 failed; 0 ignored"),
"the generated tests pass:\n{tests}"
);
// APIs: tokens instead of cookies, and 401 without one.
glacier(&root, &["new", "tokens"]);
let app = root.join("tokens");
glacier(&app, &["generate", "authentication", "--api"]);
std::fs::write(
app.join("src/controllers/me_controller.glacier"),
"alias Authentication.current_user\n\ncontroller Me do\n def show(ctx) do\n email_address = current_user(&ctx).map(fn u -> u.email_address.clone() end)\n json [email_address: email_address]\n end\nend\n",
)
.unwrap();
let mods = std::fs::read_to_string(app.join("src/controllers/mod.rs")).unwrap();
std::fs::write(
app.join("src/controllers/mod.rs"),
mods.replace("glacier::controllers!(", "glacier::controllers!(me, "),
)
.unwrap();
add_scope(
&app,
"\n scope \"/\" through api, authenticated {\n get \"/me\" => Me::show, as me;\n }\n",
);
std::fs::write(
app.join("tests/controllers/me_controller.test.glacier"),
"test \"needs a token\" do\n res = get \"/me\"\n assert res.status == :unauthorized\nend\n\ntest \"knows who the token belongs to\" do\n user = insert(User, email_address: \"user@example.com\")\n token = Session.start(user.id, None)!\n glacier::testing::bearer(&token)\n res = get \"/me\"\n assert res.status == :ok\n assert res.body == r#\"{\"email_address\":\"user@example.com\"}\"#\nend\n",
)
.unwrap();
let suite = std::fs::read_to_string(app.join("tests/glacier.rs")).unwrap();
std::fs::write(
app.join("tests/glacier.rs"),
suite.replace(
"glacier::tests!(",
"glacier::tests!(controllers::me_controller, ",
),
)
.unwrap();
glacier(&app, &["fmt", "--check"]);
let tests = cargo(&app, &["test"]);
// pages 1, sessions 3, registrations 2, user 4, me 2.
assert!(
tests.contains("12 passed; 0 failed; 0 ignored"),
"the generated tests pass:\n{tests}"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn api_app_generates_json_controllers() {
let root = std::env::temp_dir().join(format!("glacier-api-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
glacier(&root, &["new", "service", "--api"]);
let app = root.join("service");
assert!(!app.join("src/views.rs").exists(), "no views");
assert!(!app.join("public").exists(), "no assets");
// In an API app the generators write JSON controllers, with no views.
glacier(
&app,
&[
"generate",
"scaffold",
"Post",
"title:string",
"body:text:optional",
],
);
glacier(
&app,
&[
"generate",
"scaffold",
"Note",
"text:string",
"--dialect",
"rust",
],
);
glacier(&app, &["generate", "controller", "Reports", "summary"]);
glacier(&app, &["generate", "authentication"]);
glacier(&app, &["generate", "job", "SendReport", "report_id:bigint"]);
glacier(&app, &["generate", "policy", "Post"]);
std::fs::create_dir_all(app.join("tests/services")).unwrap();
std::fs::write(
app.join("tests/services/post_policy.test.glacier"),
"alias Services.PostPolicy\n\ntest \"denies until decided\" do\n user = insert(User)\n post = insert(Post)\n refute PostPolicy.update(&user, &post)\n refute PostPolicy.index(&user)\nend\n",
)
.unwrap();
std::fs::create_dir_all(app.join("tests/jobs")).unwrap();
std::fs::write(
app.join("tests/jobs/send_report.test.glacier"),
"test \"runs a queued report\" do\n job = SendReport { report_id: 7 }\n job.enqueue()!\n assert glacier::testing::perform_jobs()! == 1\nend\n",
)
.unwrap();
let suite = std::fs::read_to_string(app.join("tests/glacier.rs")).unwrap();
std::fs::write(
app.join("tests/glacier.rs"),
suite.replace(
"glacier::tests!(",
"glacier::tests!(jobs::send_report, services::post_policy, ",
),
)
.unwrap();
assert!(!app.join("src/views").exists(), "no views");
let routes = std::fs::read_to_string(app.join("src/routes.rs")).unwrap();
assert!(
routes.contains(
"resources \"/posts\" => Posts, only [index, show, create, update, destroy];"
),
"{routes}"
);
assert!(routes.contains("post \"/session\""), "{routes}");
glacier(&app, &["fmt", "--check"]);
let tests = cargo(&app, &["test"]);
// home 2, posts 5, post 2, reports 1, sessions 3, registrations 2, user 4,
// send_report 1, post_policy 1.
assert!(
tests.contains("21 passed; 0 failed; 0 ignored"),
"the generated tests pass:\n{tests}"
);
// The Rust scaffold's own: note 2, notes 4.
assert!(tests.contains("2 passed; 0 failed"), "{tests}");
assert!(tests.contains("4 passed; 0 failed"), "{tests}");
std::fs::remove_dir_all(&root).ok();
}