use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use serde_json::{json, Value};
use crate::ui;
pub const TEST_ROOTS: &[&str] = &[
"ReplicatedStorage/test",
"ServerScriptService/test",
"StarterPlayer/StarterPlayerScripts/test",
];
const STARTER_SPECS: &[(&str, &str)] = &[
("tests/shared/hello.spec.luau", "shared"),
("tests/server/hello.spec.luau", "server"),
("tests/client/hello.spec.luau", "client"),
];
fn starter_spec(area: &str) -> String {
format!(
"--!strict\n\nreturn function()\n\
\tdescribe(\"{area}\", function()\n\
\t\tit(\"runs\", function()\n\
\t\t\texpect(true).to.equal(true)\n\
\t\tend)\n\
\tend)\n\
end\n"
)
}
pub fn ensure_test_folders(project_dir: &Path) -> Result<()> {
let mut written = Vec::new();
for (rel_path, area) in STARTER_SPECS {
let file = project_dir.join(rel_path);
fs::create_dir_all(file.parent().expect("spec paths have a parent"))?;
if file.exists() {
continue;
}
fs::write(&file, starter_spec(area))
.with_context(|| format!("failed to write {}", file.display()))?;
written.push(*area);
}
if written.is_empty() {
ui::ok("tests/ already present");
} else {
ui::ok(&format!("created tests/ ({})", written.join(", ")));
}
Ok(())
}
pub fn companion_config() -> String {
let roots = TEST_ROOTS
.iter()
.map(|r| format!("\t\"{r}\",\n"))
.collect::<String>();
format!(
"# Where TestEZ Companion looks for .spec files. Descendants are\n\
# included, so these are the three `test` instances rproj maps in\n\
# default.project.json from tests/shared, tests/server, tests/client.\n\
roots = [\n{roots}]\n"
)
}
pub const TESTEZ_STD: &str = r#"---
globals:
FIXME:
args:
- required: false
type: string
FOCUS:
args: []
SKIP:
args: []
afterAll:
args:
- type: function
afterEach:
args:
- type: function
beforeAll:
args:
- type: function
beforeEach:
args:
- type: function
describe:
args:
- type: string
- type: function
describeFOCUS:
args:
- type: string
- type: function
describeSKIP:
args:
- type: string
- type: function
expect:
args:
- type: any
it:
args:
- type: string
- type: function
itFIXME:
args:
- type: string
- type: function
itFOCUS:
args:
- type: string
- type: function
itSKIP:
args:
- type: string
- type: function
"#;
pub fn ensure_selene_std(project_dir: &Path) -> Result<()> {
let path = project_dir.join("testez.yml");
if path.exists() {
ui::ok("testez.yml already exists");
return Ok(());
}
fs::write(&path, TESTEZ_STD).with_context(|| format!("failed to write {}", path.display()))?;
ui::ok("wrote testez.yml (selene standard library)");
Ok(())
}
const TESTEZ_GLOBALS: &[&str] = &[
"FIXME",
"FOCUS",
"SKIP",
"afterAll",
"afterEach",
"beforeAll",
"beforeEach",
"describe",
"describeFOCUS",
"describeSKIP",
"expect",
"it",
"itFIXME",
"itFOCUS",
"itSKIP",
];
pub fn ensure_tests_luaurc(project_dir: &Path) -> Result<()> {
let path = project_dir.join("tests").join(".luaurc");
let mut config = if path.exists() {
let text = fs::read_to_string(&path)?;
match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(map)) => map,
_ => {
ui::skip("tests/.luaurc exists but couldn't be parsed, leaving it alone");
return Ok(());
}
}
} else {
serde_json::Map::new()
};
if config.contains_key("globals") {
ui::ok("tests/.luaurc already configured");
return Ok(());
}
config.insert("globals".to_string(), json!(TESTEZ_GLOBALS));
fs::create_dir_all(path.parent().expect("joined path has a parent"))?;
fs::write(&path, format!("{}\n", serde_json::to_string_pretty(&Value::Object(config))?))
.with_context(|| format!("failed to write {}", path.display()))?;
ui::ok("wrote tests/.luaurc (TestEZ globals)");
Ok(())
}
pub fn ensure_companion_config(project_dir: &Path) -> Result<()> {
let path = project_dir.join("testez-companion.toml");
if path.exists() {
ui::ok("testez-companion.toml already exists");
return Ok(());
}
fs::write(&path, companion_config())
.with_context(|| format!("failed to write {}", path.display()))?;
ui::ok("wrote testez-companion.toml");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_service_rooted_and_match_the_scaffolded_tree() {
let config = companion_config();
for root in TEST_ROOTS {
assert!(config.contains(root), "{root} missing from:\n{config}");
assert!(!root.starts_with('/'), "{root} should not start with a slash");
assert!(!root.starts_with("game/"), "{root} should be service-rooted, not game-rooted");
}
assert!(config.starts_with('#'), "config should explain itself");
assert!(config.contains("roots = ["));
}
#[test]
fn testez_std_declares_the_globals_selene_would_otherwise_reject() {
for global in ["describe", "it", "expect", "beforeEach", "afterAll", "itFOCUS", "describeSKIP"] {
assert!(TESTEZ_STD.contains(&format!(" {global}:")), "{global} missing from testez.yml");
}
assert!(TESTEZ_STD.starts_with("---"), "selene std files are YAML documents");
}
#[test]
fn roots_name_the_test_instances_the_project_file_creates() {
assert_eq!(
TEST_ROOTS,
["ReplicatedStorage/test", "ServerScriptService/test", "StarterPlayer/StarterPlayerScripts/test"]
);
}
#[test]
fn luau_lsp_globals_match_the_selene_standard_library() {
let declared_to_selene: Vec<&str> = TESTEZ_STD
.lines()
.filter(|l| l.starts_with(" ") && !l.starts_with(" ") && l.trim_end().ends_with(':'))
.map(|l| l.trim().trim_end_matches(':'))
.collect();
assert!(!declared_to_selene.is_empty(), "parsed nothing out of testez.yml");
assert_eq!(
declared_to_selene, TESTEZ_GLOBALS,
"selene's testez.yml and luau-lsp's tests/.luaurc must declare the same globals"
);
}
#[test]
fn starter_specs_use_the_testez_shape() {
let spec = starter_spec("shared");
assert!(spec.starts_with("--!strict"), "{spec}");
assert!(spec.contains("return function()"), "{spec}");
assert!(spec.contains("describe(\"shared\""), "{spec}");
assert!(spec.contains("it(\"runs\""), "{spec}");
assert!(spec.trim_end().ends_with("end"), "{spec}");
for line in spec.lines() {
assert!(
!line.starts_with(' '),
"generated spec must be tab-indented with no stray leading spaces:
{spec}"
);
}
}
}