mod common;
use std::path::{Path, PathBuf};
use std::process::Command;
use common::{DOWN, ENTER, ESC, LEFT, Session};
fn hub_creation(name: &str) -> Session {
let mut session = Session::start(&projects_root(), &[]);
session.wait_for("Tasks");
session.send(DOWN);
session.send(ENTER);
session.wait_for("Project folder name");
session.send(name);
session.send(ENTER);
session.wait_for("Review");
session.send("Start point");
session.send(ENTER);
session.wait_for("Composition");
session
}
fn hub_minimal_review(session: &mut Session) {
session.send("Expert");
session.send(ENTER);
session.wait_for("Dependencies");
session.send("None");
session.send(ENTER);
session.wait_for("Capabilities");
session.send(ENTER);
session.wait_for("Review");
}
#[test]
#[ignore = "requires recorded machine setup and optional real Rojo template validation; run serially"]
fn hub_creation_cancel_preserves_draft_boundary_and_restores_terminal() {
let name = unique_name("hub-cancel");
let path = projects_root().join(&name);
let mut session = hub_creation(&name);
hub_minimal_review(&mut session);
assert!(!path.exists());
session.send(ESC);
session.wait_for("Discard this draft");
session.send(ESC);
session.wait_for("Review");
session.send("\x03");
session.wait_for("Cancelled. Nothing created.");
session.send(ESC);
let result = session.finish();
assert_eq!(result.code, 0, "{}", result.text);
assert!(!path.exists());
}
#[test]
#[ignore = "requires recorded machine setup and optional real Rojo template validation; run serially"]
fn hub_creation_refuses_a_concurrent_destination_without_overwriting() {
let name = unique_name("hub-concurrent");
let project = LiveProject {
path: projects_root().join(&name),
};
let mut session = hub_creation(&name);
hub_minimal_review(&mut session);
std::fs::create_dir(project.path()).unwrap();
std::fs::write(project.path().join("owner.txt"), "preserve").unwrap();
session.send(ENTER);
session.wait_for("reviewed files?");
session.send(ENTER);
session.wait_for("Destination already exists");
session.send("\x03");
session.wait_for("Cancelled. Nothing created.");
session.send(ESC);
assert_eq!(session.finish().code, 0);
assert_eq!(project.read("owner.txt"), "preserve");
assert!(!project.exists("default.project.json"));
}
#[test]
#[ignore = "requires provisioned Git/Rokit/Rojo; creates only a unique temporary project; run serially"]
fn hub_creation_confirm_hands_the_reviewed_graph_to_the_existing_executor() {
let dirs = directories::ProjectDirs::from("", "", "rproj").unwrap();
let setups = dirs.config_dir().join("setups");
std::fs::create_dir_all(&setups).unwrap();
let setup = tempfile::Builder::new()
.prefix("rproj-hub-save-")
.suffix(".toml")
.tempfile_in(&setups)
.unwrap()
.into_temp_path();
let setup_name = setup.file_stem().unwrap().to_str().unwrap().to_owned();
std::fs::remove_file(&setup).unwrap();
let name = unique_name("hub-create");
let project = LiveProject {
path: projects_root().join(&name),
};
let mut session = hub_creation(&name);
hub_minimal_review(&mut session);
session.send("Save named setup");
session.send(ENTER);
session.wait_for("New setup name");
session.send(&setup_name);
session.send(ENTER);
session.wait_for("Save setup on Create");
session.send(&"\x7f".repeat("Save named setup".len()));
session.send("Create");
assert!(!project.path().exists());
assert!(!setup.exists());
session.send(ENTER);
session.wait_for("reviewed files?");
assert!(!project.path().exists());
session.send(ENTER);
session.wait_for("is ready");
session.wait_for("Press Enter to return the project.");
session.send(ENTER);
session.wait_for("Project actions");
session.send("\x03");
session.wait_for("Tasks");
session.send(ESC);
let result = session.finish();
assert_eq!(result.code, 0, "{}", result.text);
let graph: toml::Value = toml::from_str(&project.read("rproj.toml")).unwrap();
assert_eq!(graph["package_workflow"].as_str(), Some("none"));
assert!(graph["capabilities"].as_table().unwrap().is_empty());
assert!(project.exists("src/shared"));
assert!(project.exists("default.project.json"));
assert!(!project.exists("wally.toml"));
assert!(!project.exists("rokit.toml"));
let saved = std::fs::read(&setup).unwrap();
assert_eq!(
saved,
std::fs::read(project.path().join("rproj.toml")).unwrap()
);
let replay_name = unique_name("hub-replay");
let mut replay = hub_creation(&replay_name);
replay.send(&setup_name);
replay.send(ENTER);
replay.wait_for("Review");
replay.send("\x03");
replay.wait_for("Cancelled. Nothing created.");
replay.send(ESC);
assert_eq!(replay.finish().code, 0);
assert_eq!(std::fs::read(&setup).unwrap(), saved);
assert!(!projects_root().join(replay_name).exists());
}
struct LiveProject {
path: PathBuf,
}
impl LiveProject {
fn scaffold(name: &str, packages: &[&str], submodules: bool) -> Self {
let root = projects_root();
let name = unique_name(name);
let path = root.join(&name);
let mut session = Session::start(&root, &["new", &name]);
session.wait_for("How should this project get its dependencies?");
if submodules {
session.send("git-submodules");
session.wait_for("git-submodules - ");
}
session.send(ENTER);
session.wait_for("How do you want to pick packages?");
session.send(&format!("{DOWN}{ENTER}")); session.wait_for("Pick every package this project needs");
for key in packages.iter().filter(|key| **key != "testez") {
session.send(key);
session.wait_for(&format!("{key} - "));
session.send(" ");
session.send(&"\x7f".repeat(key.len()));
}
session.send(ENTER);
session.wait_for("What should this project do?");
for key in ["lint", "format", "typecheck", "gate", "editor", "ci"]
.into_iter()
.chain(packages.contains(&"testez").then_some("test"))
{
session.send(key);
session.wait_for(&format!("{key} - "));
session.send(" ");
session.send(&"\x7f".repeat(key.len()));
}
session.send(ENTER);
if packages.contains(&"testez") && !submodules {
session.wait_for("test:");
session.send("testez");
session.wait_for("testez - ");
session.send(ENTER);
}
session.wait_for("Create it?");
session.send(ENTER);
session.wait_for("is ready");
let outcome = session.finish();
assert_eq!(outcome.code, 0, "scaffold failed:\n{}", outcome.text);
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
fn read(&self, relative: &str) -> String {
std::fs::read_to_string(self.path.join(relative))
.unwrap_or_else(|e| panic!("reading {relative}: {e}"))
}
fn exists(&self, relative: &str) -> bool {
self.path.join(relative).exists()
}
fn link_file(&self, dir: &str, key: &str) -> String {
let path = self.path.join(dir);
let entries: Vec<PathBuf> = std::fs::read_dir(&path)
.unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
.filter_map(|entry| Some(entry.ok()?.path()))
.collect();
let found = entries.iter().find(|entry| {
entry
.file_stem()
.is_some_and(|stem| stem.eq_ignore_ascii_case(key))
});
match found {
Some(file) => std::fs::read_to_string(file).expect("read link file"),
None => panic!(
"no link file for `{key}` in {}. It holds: {}",
path.display(),
entries
.iter()
.filter_map(|e| e.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect::<Vec<_>>()
.join(", ")
),
}
}
fn gate(&self) -> i32 {
let (code, output) = run(&self.path, "lute", &["run", "check"]);
if code != 0 {
eprintln!("Quality gate failed (exit {code}):\n{output}");
}
code
}
}
impl Drop for LiveProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn projects_root() -> PathBuf {
let dirs = directories::ProjectDirs::from("", "", "rproj").expect("config directory");
let text = std::fs::read_to_string(dirs.config_dir().join("config.toml"))
.expect("live tests require an already provisioned machine");
let config: toml::Value = toml::from_str(&text).expect("valid machine configuration");
assert!(
config
.get("last_checked")
.and_then(toml::Value::as_str)
.is_some(),
"run machine setup explicitly before live tests"
);
let root = config
.get("roblox_projects_root")
.and_then(toml::Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("USERPROFILE").expect("USERPROFILE"))
.join("Documents/RobloxProjects")
});
assert!(
root.is_dir(),
"live test project root must already exist: {}",
root.display()
);
root
}
fn unique_name(label: &str) -> String {
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT: AtomicUsize = AtomicUsize::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let name = format!(
"rproj-audit-{label}-{}-{nanos}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
);
assert!(
!projects_root().join(&name).exists(),
"scratch project already exists"
);
name
}
fn run(dir: &Path, tool: &str, args: &[&str]) -> (i32, String) {
let shim = PathBuf::from(std::env::var_os("USERPROFILE").expect("USERPROFILE"))
.join(".rokit")
.join("bin")
.join(format!("{tool}.exe"));
let program = if shim.is_file() {
shim
} else {
PathBuf::from(tool)
};
let output = Command::new(&program)
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("spawning {}: {e}", program.display()));
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
(output.status.code().unwrap_or(-1), text)
}
#[test]
#[ignore = "uses real machine config/setup paths and optional Rojo template validation; run serially"]
fn invalid_saved_setups_refuse_before_reconfiguration_or_creation() {
let root = projects_root();
let dirs = directories::ProjectDirs::from("", "", "rproj").expect("config directory");
let config_path = dirs.config_dir().join("config.toml");
let machine_before = std::fs::read(&config_path).expect("read machine config");
let setups = dirs.config_dir().join("setups");
std::fs::create_dir_all(&setups).expect("create setup directory");
let scratch = tempfile::Builder::new()
.prefix("rproj-refusal-")
.tempdir_in(&root)
.expect("create unique project scratch directory");
let project_prefix = scratch.path().file_name().unwrap().to_str().unwrap();
let output_path = tempfile::Builder::new()
.prefix("rproj-refusal-output-")
.suffix(".toml")
.tempfile_in(&setups)
.expect("reserve output setup sentinel")
.into_temp_path();
let output_name = output_path.file_stem().unwrap().to_str().unwrap();
let sentinel = b"preserve this existing setup byte-for-byte\n";
std::fs::write(&output_path, sentinel).unwrap();
for (label, contents, error) in [
("missing", None, "no saved setup called"),
("malformed", Some("mode = [".to_string()), "failed to parse"),
(
"jest-with-none",
Some("package_workflow = 'none'\n[capabilities]\ntest = 'jest-roblox'\n".to_string()),
"selects Jest Roblox without Wally",
),
(
"jest-with-submodules",
Some(
"package_workflow = 'git-submodules'\n[capabilities]\ntest = 'jest-roblox'\n"
.to_string(),
),
"selects Jest Roblox without Wally",
),
] {
let setup_path = tempfile::Builder::new()
.prefix(&format!("rproj-refusal-{label}-"))
.suffix(".toml")
.tempfile_in(&setups)
.expect("reserve input setup fixture")
.into_temp_path();
let setup_name = setup_path.file_stem().unwrap().to_str().unwrap();
if let Some(text) = &contents {
std::fs::write(&setup_path, text).expect("write owned setup fixture");
} else {
std::fs::remove_file(&setup_path).expect("remove reserved missing setup");
}
let project_name = format!("{project_prefix}/{label}/project");
let outcome = Session::start(
&root,
&[
"new",
&project_name,
"--like",
setup_name,
"--reconfigure",
"--save-setup",
output_name,
],
)
.finish();
assert_eq!(outcome.code, 1, "{label}: {}", outcome.text);
outcome.assert_contains(error);
outcome.assert_contains(label);
for forbidden in [
"System apps",
"Rokit-managed CLI tools",
"Machine setup",
"Scaffolding",
"Create it?",
"is ready",
] {
outcome.assert_lacks(forbidden);
}
outcome.assert_lacks(&format!("saved setup `{output_name}`"));
assert!(
!scratch.path().join(label).exists(),
"{label}: refusal created the destination or its parent"
);
assert_eq!(std::fs::read(&config_path).unwrap(), machine_before);
assert_eq!(std::fs::read(&output_path).unwrap(), sentinel);
let fixture_path = setup_path.to_path_buf();
match contents {
Some(text) => {
assert_eq!(std::fs::read_to_string(&setup_path).unwrap(), text);
setup_path
.close()
.expect("remove owned input setup fixture");
}
None => assert!(!setup_path.exists(), "missing setup was created"),
}
assert!(!fixture_path.exists());
}
let output_path_check = output_path.to_path_buf();
let scratch_path = scratch.path().to_path_buf();
output_path
.close()
.expect("remove owned output setup sentinel");
scratch
.close()
.expect("remove empty project scratch directory");
assert!(!output_path_check.exists());
assert!(!scratch_path.exists());
}
#[test]
#[ignore = "requires provisioned Rokit/Wally/Git, network and shared setup directory; run serially"]
fn saved_setup_replays_workflow_packages_capabilities_and_dropped_files() {
let root = projects_root();
let dirs = directories::ProjectDirs::from("", "", "rproj").expect("config directory");
let setups = dirs.config_dir().join("setups");
std::fs::create_dir_all(&setups).expect("create setup directory");
for workflow in ["wally", "git-submodules"] {
let scratch = tempfile::Builder::new()
.prefix("rproj-replay-")
.tempdir_in(&root)
.expect("create unique replay scratch directory");
let setup = tempfile::Builder::new()
.prefix("rproj-replay-")
.suffix(".toml")
.tempfile_in(&setups)
.expect("reserve unique saved setup")
.into_temp_path();
let setup_name = setup.file_stem().unwrap().to_str().unwrap();
let parent_name = scratch.path().file_name().unwrap().to_str().unwrap();
let original_name = format!("{parent_name}/original");
let replay_name = format!("{parent_name}/replayed");
let original = scratch.path().join("original");
let replayed = scratch.path().join("replayed");
let mut session =
Session::start(&root, &["new", &original_name, "--save-setup", setup_name]);
session.wait_for("How should this project get its dependencies?");
session.send(workflow);
session.wait_for(&format!("{workflow} - "));
session.send(ENTER);
session.wait_for("How do you want to pick packages?");
session.send(&format!("{DOWN}{ENTER}")); session.wait_for("Pick every package this project needs");
session.send(LEFT);
for key in ["charm", "promise"] {
session.send(key);
session.wait_for(&format!("{key} - "));
session.send(" ");
session.send(&"\x7f".repeat(key.len()));
}
session.send(ENTER);
session.wait_for("What should this project do?");
session.send(LEFT);
for key in ["format", "lint"] {
session.send(key);
session.wait_for(&format!("{key} - "));
session.send(" ");
session.send(&"\x7f".repeat(key.len()));
}
session.send(ENTER);
session.wait_for("Create it?");
session.send("customize");
session.wait_for("customize - ");
session.send(ENTER);
session.wait_for("Files to keep");
session.send(".gitignore");
session.wait_for(".gitignore - ");
session.send(&format!(" {ENTER}")); session.wait_for("Create it?");
session.send(ENTER);
session.wait_for("is ready");
let created = session.finish();
assert_eq!(created.code, 0, "{workflow}: {}", created.text);
created.assert_contains(&format!("saved setup `{setup_name}`"));
let read_toml = |path: &Path| -> toml::Value {
let text = std::fs::read_to_string(path)
.unwrap_or_else(|error| panic!("reading {}: {error}", path.display()));
toml::from_str(&text).expect("valid generated TOML")
};
let expected: toml::Value = toml::from_str(&format!(
"mode = 'expert'\npackage_workflow = '{workflow}'\n\
packages = ['charm', 'promise']\ndropped = ['.gitignore']\n\
[capabilities]\nformat = 'stylua'\nlint = 'selene'\n"
))
.unwrap();
assert_eq!(read_toml(&original.join("rproj.toml")), expected);
assert_eq!(read_toml(&setup), expected);
let saved_bytes = std::fs::read(&setup).unwrap();
let mut session = Session::start(&root, &["new", &replay_name, "--like", setup_name]);
session.wait_for("Create it?");
session.send(ENTER);
session.wait_for("is ready");
let replay = session.finish();
assert_eq!(replay.code, 0, "{workflow}: {}", replay.text);
replay.assert_contains(&format!("using saved setup `{setup_name}`"));
for prompt in [
"How should this project get its dependencies?",
"How do you want to pick packages?",
"Pick every package this project needs",
"What should this project do?",
"Files to keep",
] {
replay.assert_lacks(prompt);
}
let mut expected_replay = expected;
expected_replay["mode"] = format!("like:{setup_name}").into();
assert_eq!(read_toml(&replayed.join("rproj.toml")), expected_replay);
assert_eq!(
std::fs::read(&setup).unwrap(),
saved_bytes,
"replay rewrote setup"
);
for project in [&original, &replayed] {
for kept in [
"default.project.json",
"stylua.toml",
".gitattributes",
"selene.toml",
] {
assert!(
project.join(kept).is_file(),
"missing {kept} in {project:?}"
);
}
for absent in [".gitignore", ".vscode/settings.json", ".lute/check.luau"] {
assert!(
!project.join(absent).exists(),
"unexpected {absent} in {project:?}"
);
}
if workflow == "wally" {
let manifest = read_toml(&project.join("wally.toml"));
let dependencies = manifest["dependencies"].as_table().unwrap();
assert_eq!(
dependencies.keys().map(String::as_str).collect::<Vec<_>>(),
["charm", "promise"]
);
for key in ["charm", "promise"] {
assert!(
std::fs::read_dir(project.join("Packages"))
.unwrap()
.any(|entry| {
let path = entry.unwrap().path();
path.is_file()
&& path
.file_stem()
.is_some_and(|stem| stem.eq_ignore_ascii_case(key))
}),
"missing installed Wally package {key} in {project:?}"
);
}
assert!(!project.join("modules").exists());
} else {
for module in ["Charm", "Promise"] {
assert!(project.join(format!("modules/{module}.luau")).is_file());
}
assert!(project.join(".gitmodules").is_file());
assert!(!project.join("wally.toml").exists());
}
let pins = read_toml(&project.join("rokit.toml"));
let tools = pins["tools"].as_table().expect("project-local tool pins");
for source in ["JohnnyMorganz/StyLua@", "Kampfkarren/selene@"] {
assert!(
tools
.values()
.any(|pin| pin.as_str().is_some_and(|pin| pin.starts_with(source))),
"missing selected {source} pin in {project:?}: {pins}"
);
}
}
assert_eq!(
read_toml(&original.join("rokit.toml"))["tools"],
read_toml(&replayed.join("rokit.toml"))["tools"],
"replay changed project-local tool pins"
);
let setup_path = setup.to_path_buf();
let scratch_path = scratch.path().to_path_buf();
setup.close().expect("remove temporary saved setup");
scratch
.close()
.expect("remove original and replayed scratch projects");
assert!(!setup_path.exists());
assert!(!scratch_path.exists());
}
}
#[test]
#[ignore = "requires provisioned Rokit/Wally, Studio with JestRobloxRunner, network; run serially"]
fn jest_starter_specs_pass_and_report_failure() {
fn package_snapshot(project: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
fn collect(
root: &Path,
path: &Path,
files: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>,
) {
for entry in std::fs::read_dir(path).unwrap() {
let entry = entry.unwrap();
if entry.file_type().unwrap().is_dir() {
collect(root, &entry.path(), files);
} else {
files.insert(
entry.path().strip_prefix(root).unwrap().to_path_buf(),
std::fs::read(entry.path()).unwrap(),
);
}
}
}
let mut files = std::collections::BTreeMap::new();
for folder in ["Packages", "DevPackages"] {
collect(project, &project.join(folder), &mut files);
}
files
}
let root = projects_root();
let plugin = PathBuf::from(std::env::var_os("LOCALAPPDATA").expect("LOCALAPPDATA"))
.join("Roblox/Plugins/JestRobloxRunner.rbxm");
assert!(
plugin.is_file(),
"install the Jest Studio runner before running this live test"
);
let scratch = tempfile::Builder::new()
.prefix("rproj-jest-")
.tempdir_in(&root)
.expect("create unique Jest scratch directory");
let name = format!(
"{}/project",
scratch.path().file_name().unwrap().to_str().unwrap()
);
let project = scratch.path().join("project");
let mut session = Session::start(&root, &["new", &name]);
session.wait_for("How should this project get its dependencies?");
session.send(ENTER); session.wait_for("How do you want to pick packages?");
session.send(&format!("{DOWN}{ENTER}")); session.wait_for("Pick every package this project needs");
session.send(&format!("{LEFT}{ENTER}")); session.wait_for("What should this project do?");
session.send(LEFT);
session.send("test");
session.wait_for("test - ");
session.send(&format!(" {ENTER}"));
session.wait_for("test:");
session.send("jest-roblox");
session.wait_for("jest-roblox - ");
session.send(ENTER);
session.wait_for("Jest execution:");
session.send(ENTER); session.wait_for("Create it?");
session.send(ENTER);
session.wait_for("is ready");
let scaffold = session.finish();
assert_eq!(scaffold.code, 0, "{}", scaffold.text);
let manifest: toml::Value = toml::from_str(
&std::fs::read_to_string(project.join("rokit.toml")).expect("generated Rokit manifest"),
)
.expect("valid Rokit manifest");
let tools = manifest["tools"].as_table().expect("project tool pins");
for source in ["christopher-buss/jest-roblox-cli@", "UpliftGames/wally@"] {
assert!(
tools
.values()
.any(|pin| pin.as_str().is_some_and(|pin| pin.starts_with(source))),
"missing {source} pin: {manifest}\n{}",
scaffold.text
);
}
let packages_before = package_snapshot(&project);
let passing = Session::start(
&project,
&[
"test",
"--backend",
"studio-cli",
"--no-color",
"--outputFile",
"passing.json",
],
)
.finish();
assert_eq!(passing.code, 0, "{}", passing.text);
assert_eq!(package_snapshot(&project), packages_before);
let report = |name: &str| -> serde_json::Value {
let text = std::fs::read_to_string(project.join(name)).expect("Jest result report");
serde_json::from_str(&text).expect("valid Jest result JSON")
};
let passed = report("passing.json");
assert_eq!(passed["success"], true, "{passed}");
assert_eq!(passed["numPassedTests"], 3, "{passed}\n{}", passing.text);
assert_eq!(passed["numFailedTests"], 0, "{passed}");
let suites = passed["testResults"].as_array().expect("per-file results");
assert_eq!(suites.len(), 3, "{passed}");
for area in ["shared", "server", "client"] {
let path = format!("tests/{area}/hello.spec.luau");
let suite = suites
.iter()
.find(|suite| suite["testFilePath"] == path)
.unwrap_or_else(|| panic!("missing {path}: {passed}"));
assert_eq!(suite["numPassingTests"], 1, "{suite}");
assert_eq!(suite["testResults"][0]["status"], "passed", "{suite}");
}
let spec_path = project.join("tests/shared/hello.spec.luau");
let spec = std::fs::read_to_string(&spec_path).expect("generated starter spec");
assert_eq!(spec.matches("expect(1 + 1).toBe(2)").count(), 1, "{spec}");
let broken = spec
.replace("expect(1 + 1).toBe(2)", "expect(1 + 1).toBe(987654)")
.replace("it(\"runs\"", "it(\"rproj deliberate failure\"");
std::fs::write(&spec_path, &broken).expect("break owned starter spec");
let failing = Session::start(
&project,
&[
"test",
"--backend",
"studio-cli",
"--no-color",
"--outputFile",
"failing.json",
],
)
.finish();
assert_eq!(failing.code, 1, "{}", failing.text);
assert_eq!(package_snapshot(&project), packages_before);
failing.assert_contains("rproj deliberate failure");
failing.assert_contains("987654");
failing.assert_contains("reported test failures (exit code 1)");
let failed = report("failing.json");
assert_eq!(failed["numPassedTests"], 2, "{failed}");
assert_eq!(failed["numFailedTests"], 1, "{failed}");
assert_eq!(failed["success"], false, "{failed}");
let failed_suites = failed["testResults"].as_array().expect("per-file results");
assert_eq!(failed_suites.len(), 3, "{failed}");
let shared = failed_suites
.iter()
.find(|suite| suite["testFilePath"] == "tests/shared/hello.spec.luau")
.expect("shared starter spec result");
assert_eq!(shared["numFailingTests"], 1, "{shared}");
assert_eq!(shared["testResults"][0]["status"], "failed", "{shared}");
assert_eq!(
shared["testResults"][0]["title"], "rproj deliberate failure",
"{shared}"
);
assert_eq!(
std::fs::read_to_string(spec_path).unwrap(),
broken,
"rproj test must preserve the user's failing spec"
);
println!(
"Jest regression: 3 starter specs passed; deliberate failure returned 1 with 2 passed / 1 failed."
);
}
#[test]
#[ignore]
fn saying_no_to_everything_yields_only_the_rojo_basics() {
let root = projects_root();
let name = unique_name("rproj-bare-basics");
let path = root.join(&name);
let mut session = Session::start(&root, &["new", &name]);
session.wait_for("How should this project get its dependencies?");
session.send("none");
session.wait_for("none - ");
session.send(ENTER);
session.wait_for("What should this project do?");
session.send(&format!("{LEFT}{ENTER}"));
session.wait_for("Create it?");
session.send("customize");
session.wait_for("customize - ");
session.send(ENTER);
session.wait_for("Files to keep");
session.send(&format!("{LEFT}{ENTER}")); session.wait_for("Create it?");
session.send(ENTER);
session.wait_for("is ready");
let outcome = session.finish();
assert_eq!(outcome.code, 0, "scaffold failed:\n{}", outcome.text);
let mut entries: Vec<String> = std::fs::read_dir(&path)
.expect("read the project dir")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
entries.sort();
assert_eq!(
entries,
[".git", "default.project.json", "src"],
"a minimal answer must produce nothing else:\n{}",
outcome.text
);
assert!(path.join("default.project.json").is_file());
assert!(path.join("src").join("shared").is_dir());
let _ = std::fs::remove_dir_all(&path);
}
#[test]
#[ignore = "requires an already provisioned machine"]
fn confirmation_and_cancellation_preserve_a_concurrently_created_directory() {
for cancel in [true, false] {
let root = projects_root();
let name = unique_name("concurrent");
let path = root.join(&name);
let mut session = Session::start(&root, &["new", &name]);
session.wait_for("How should this project get its dependencies?");
session.send("none");
session.wait_for("none - ");
session.send(ENTER);
session.wait_for("What should this project do?");
session.send(&format!("{LEFT}{ENTER}"));
session.wait_for("Create it?");
assert!(!path.exists());
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("user.txt"), "preserve me").unwrap();
let project = LiveProject { path };
if cancel {
session.send("cancel");
session.wait_for("cancel - ");
}
session.send(ENTER);
let outcome = session.finish();
assert_eq!(outcome.code, if cancel { 0 } else { 1 }, "{}", outcome.text);
assert_eq!(project.read("user.txt"), "preserve me");
assert!(!project.exists("default.project.json"));
}
}
#[test]
#[ignore]
fn the_summary_explains_every_file_instead_of_offering_it_as_a_choice() {
let root = projects_root();
let name = unique_name("rproj-summary-report");
let path = root.join(&name);
let mut session = Session::start(&root, &["new", &name]);
session.wait_for("How should this project get its dependencies?");
session.send(ENTER); session.wait_for("How do you want to pick packages?");
session.send(&format!("{DOWN}{ENTER}")); session.wait_for("Pick every package this project needs");
session.send("promise");
session.wait_for("promise - ");
session.send(" ");
session.send(ENTER);
session.wait_for("What should this project do?");
for key in ["lint", "format", "typecheck", "gate", "editor"] {
session.send(key);
session.wait_for(&format!("{key} - "));
session.send(" ");
session.send(&"\x7f".repeat(key.len()));
}
session.send(ENTER);
session.wait_for("Create it?");
let screen = session.text();
for expected in [
"wally.toml",
"this project uses Wally",
"selene.toml",
"you chose lint",
"rokit.toml",
] {
assert!(
screen.contains(expected),
"expected {expected:?} in:\n{screen}"
);
}
assert!(screen.contains("lint (Selene)"), "{screen}");
assert!(screen.contains("format (StyLua)"), "{screen}");
for offered in ["wally.toml - ", "rokit.toml - ", "selene.toml - "] {
assert!(
!screen.contains(offered),
"{offered:?} must not be a checkbox:\n{screen}"
);
}
assert!(
!path.exists(),
"answering prompts must not create the project"
);
session.send(ESC);
let _ = session.finish();
assert!(!path.exists(), "escape must leave no project behind");
}
#[test]
#[ignore]
fn changing_an_early_answer_reasks_only_what_it_invalidates() {
let root = projects_root();
let name = unique_name("rproj-revise");
let path = root.join(&name);
let mut session = Session::start(&root, &["new", &name]);
session.wait_for("How should this project get its dependencies?");
session.send(ENTER); session.wait_for("How do you want to pick packages?");
session.send(&format!("{DOWN}{ENTER}")); session.wait_for("Pick every package this project needs");
session.send("promise");
session.wait_for("promise - ");
session.send(" ");
session.send(ENTER);
session.wait_for("What should this project do?");
session.send(&format!("{LEFT}{ENTER}"));
session.wait_for("Create it?");
let before = session.text();
assert!(before.contains("Dependencies Wally"), "{before}");
assert!(before.contains("wally.toml"), "{before}");
session.send("change");
session.wait_for("change - ");
session.send(ENTER);
session.wait_for("Change which answer?");
let menu = session.text();
assert!(menu.contains("dependencies - Wally"), "{menu}");
assert!(menu.contains("packages - promise"), "{menu}");
session.send("dependencies");
session.wait_for("dependencies - ");
session.send(ENTER);
session.wait_for("changing this re-asks: packages");
session.wait_for("How should this project get its dependencies?");
session.send("none");
session.wait_for("none - ");
session.send(ENTER);
session.wait_for("Dependencies none");
let after = session.text();
assert!(
after.contains("Packages none"),
"packages were invalidated:
{after}"
);
assert!(
after
.rfind("wally.toml")
.is_none_or(|at| at < after.rfind("Dependencies none").unwrap()),
"the manifest must not survive the strategy that wanted it:
{after}"
);
session.send("cancel");
session.wait_for("cancel - ");
session.send(ENTER);
let outcome = session.finish();
assert_eq!(
outcome.code, 0,
"cancelling is a clean exit:
{}",
outcome.text
);
assert!(!path.exists(), "cancel must leave nothing behind");
}
#[test]
#[ignore]
fn a_wally_project_scaffolds_and_passes_its_own_gate() {
let project = LiveProject::scaffold("live-wally", &["charm", "testez"], false);
for file in [
"rokit.toml",
"wally.toml",
"selene.toml",
"stylua.toml",
"default.project.json",
"sourcemap.json",
"testez.yml",
".luaurc",
".lute/check.luau",
".github/workflows/ci.yml",
".gitattributes",
] {
assert!(project.exists(file), "{file} was not scaffolded");
}
let dirs = directories::ProjectDirs::from("", "", "rproj").unwrap();
let config: toml::Value =
toml::from_str(&std::fs::read_to_string(dirs.config_dir().join("config.toml")).unwrap())
.unwrap();
let vscode = config["selected_system_apps"]
.as_array()
.unwrap()
.iter()
.any(|app| app.as_str() == Some("vscode"));
assert_eq!(project.exists(".vscode/settings.json"), vscode);
let link = project.link_file("Packages", "charm");
assert!(
link.contains("export type"),
"package types were stripped:\n{link}"
);
let sourcemap = project.read("sourcemap.json").to_lowercase();
assert!(
sourcemap.contains("\"charm\""),
"charm missing from the sourcemap"
);
assert_eq!(
project.gate(),
0,
"a freshly scaffolded project failed its own gate"
);
}
#[test]
#[ignore]
fn submodule_packages_resolve_under_both_names_and_build() {
let project = LiveProject::scaffold("live-submodules", &["charmSync"], true);
assert!(project.exists("modules/Charm.luau"), "link file missing");
assert!(
project.exists("modules/CharmSync.luau"),
"link file missing"
);
assert!(project.exists("modules/submodules/default.project.json"));
let sourcemap = project.read("sourcemap.json");
for name in ["Charm", "CharmSync"] {
assert!(
sourcemap.contains(&format!("\"{name}\"")),
"{name} missing from the sourcemap"
);
}
let (code, output) = run(project.path(), "rojo", &["build", "-o", "live-check.rbxlx"]);
assert_eq!(code, 0, "rojo build failed:\n{output}");
assert!(project.exists("live-check.rbxlx"));
assert_eq!(project.gate(), 0, "submodule project failed its own gate");
}
#[test]
#[ignore]
fn the_generated_gate_rejects_bad_code_one_step_at_a_time() {
let project = LiveProject::scaffold("live-gate", &["testez"], false);
assert_eq!(project.gate(), 0, "should start green");
let spec = project
.path()
.join("tests")
.join("shared")
.join("hello.spec.luau");
let original = std::fs::read_to_string(&spec).expect("starter spec");
let (header, body) = original
.split_once("return function()")
.expect("starter spec returns its test function");
let breakages: &[(&str, &str, &str)] = &[
(
"luau-lsp: a string where a number is declared",
"local wrong: number = \"not a number\"\nprint(wrong)\n\n",
"TypeError:",
),
(
"selene: an undefined global",
"print(someUndefinedGlobalName)\n\n",
"error[undefined_variable]",
),
(
"stylua: space indentation where the config says tabs",
"local function f()\n return 1\nend\nprint(f())\n\n",
"Diff in",
),
];
for (what, addition, diagnostic) in breakages {
std::fs::write(&spec, format!("{header}{addition}return function(){body}"))
.expect("break the spec");
let (code, output) = run(project.path(), "lute", &["run", "check"]);
assert_ne!(code, 0, "the gate accepted bad code ({what}):\n{output}");
assert!(
output.contains(diagnostic),
"missing intended diagnostic ({what}):\n{output}"
);
for syntax_error in ["SyntaxError:", "error[parse_error]", "error parsing:"] {
assert!(
!output.contains(syntax_error),
"invalid syntax masked the intended failure ({what}):\n{output}"
);
}
std::fs::write(&spec, &original).expect("restore the spec");
assert_eq!(
project.gate(),
0,
"restoring should go green again ({what})"
);
}
}
#[test]
#[ignore]
fn watch_restores_submodules_in_a_fresh_clone() {
let project = LiveProject::scaffold("live-clone", &["charm"], true);
let (code, output) = run(project.path(), "git", &["add", "-A"]);
assert_eq!(code, 0, "{output}");
let (code, output) = run(
project.path(),
"git",
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"scaffold",
],
);
assert_eq!(code, 0, "{output}");
let clone = projects_root().join(unique_name("clone-copy"));
let (code, output) = run(
projects_root().as_path(),
"git",
&[
"clone",
"-q",
&project.path().display().to_string(),
&clone.display().to_string(),
],
);
assert_eq!(code, 0, "clone failed:\n{output}");
struct Cleanup(PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _cleanup = Cleanup(clone.clone());
let vendored = clone.join("modules").join("submodules").join("charm");
assert!(
std::fs::read_dir(&vendored)
.map(|mut d| d.next().is_none())
.unwrap_or(true),
"the clone already has submodule contents, so this proves nothing"
);
let session = Session::start(&clone, &["watch"]);
session.wait_for("submodules synced");
session.wait_for("Created sourcemap");
drop(session);
assert!(
std::fs::read_dir(&vendored)
.map(|mut d| d.next().is_some())
.unwrap_or(false),
"watch did not fetch the submodule"
);
assert!(
clone.join("sourcemap.json").exists(),
"no sourcemap after watch"
);
}