mod common;
use std::path::{Path, PathBuf};
use std::process::Command;
use common::{Session, DOWN, ENTER, ESC, LEFT};
struct LiveProject {
path: PathBuf,
}
impl LiveProject {
fn scaffold(name: &str, packages: &[&str], submodules: bool) -> Self {
let root = projects_root();
let path = root.join(name);
let _ = std::fs::remove_dir_all(&path);
let mut session = Session::start(&root, &["new", name]);
session.wait_for("How do you want to set up this project's packages?");
session.send(&format!("{DOWN}{ENTER}"));
session.wait_for("Pick every package this project needs");
for key in packages {
session.send(key);
session.wait_for(&format!("{key} - "));
session.send(" ");
session.send(&"\x7f".repeat(key.len()));
}
session.send(ENTER);
if !packages.is_empty() {
session.wait_for("How do you want to pull in this project's packages?");
let keys = if submodules { format!("{DOWN}{ENTER}") } else { ENTER.to_string() };
session.send(&keys);
}
session.wait_for("Tools to pin in this project");
session.send(ENTER);
session.wait_for("Files to generate");
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 {
run(&self.path, "lute", &["run", "check"]).0
}
}
impl Drop for LiveProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn projects_root() -> PathBuf {
let configured = std::env::var_os("APPDATA")
.map(|appdata| PathBuf::from(appdata).join("rproj").join("config.toml"))
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|text| {
text.lines()
.find_map(|line| line.trim().strip_prefix("roblox_projects_root = ")?.trim().strip_prefix('"')?.strip_suffix('"').map(str::to_string))
});
match configured {
Some(root) => PathBuf::from(root.replace("\\\\", "\\")),
None => PathBuf::from(std::env::var_os("USERPROFILE").expect("USERPROFILE"))
.join("Documents")
.join("RobloxProjects"),
}
}
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]
fn saying_no_to_everything_yields_only_the_rojo_basics() {
let root = projects_root();
let name = "rproj-bare-basics";
let path = root.join(name);
let _ = std::fs::remove_dir_all(&path);
let mut session = Session::start(&root, &["new", name]);
session.wait_for("How do you want to set up this project's packages?");
session.send(&format!("{DOWN}{ENTER}")); session.wait_for("Pick every package this project needs");
session.send(&format!("{LEFT}{ENTER}")); session.wait_for("Tools to pin in this project");
session.send(&format!("{LEFT}{ENTER}")); session.wait_for("Files to generate");
session.send(&format!("{LEFT}{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]
fn selecting_a_package_settles_its_manifest_instead_of_offering_to_drop_it() {
let root = projects_root();
let name = "rproj-settled-report";
let path = root.join(name);
let _ = std::fs::remove_dir_all(&path);
let mut session = Session::start(&root, &["new", name]);
session.wait_for("How do you want to set up this project's 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("How do you want to pull in this project's packages?");
session.send(ENTER); session.wait_for("Tools to pin in this project");
session.send(ENTER);
session.wait_for("Files to generate");
let screen = session.text();
for expected in [
"Already settled by your answers so far:",
"the packages you picked are installed from it",
"it is where this project's tool versions are pinned",
"To drop one of these, change the answer it follows from.",
] {
assert!(screen.contains(expected), "expected {expected:?} in:\n{screen}");
}
for offered in ["wally.toml - ", "rokit.toml - ", "selene.toml - "] {
assert!(!screen.contains(offered), "{offered:?} must not be a checkbox:\n{screen}");
}
session.send(ESC);
let _ = session.finish();
let _ = std::fs::remove_dir_all(&path);
}
#[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",
".vscode/settings.json",
] {
assert!(project.exists(file), "{file} was not scaffolded");
}
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 breakages: &[(&str, &str)] = &[
("luau-lsp: a string where a number is declared", "\nlocal wrong: number = \"not a number\"\nprint(wrong)\n"),
("selene: an undefined global", "\nprint(someUndefinedGlobalName)\n"),
("stylua: space indentation where the config says tabs", "\nlocal function f()\n return 1\nend\nprint(f())\n"),
];
for (what, addition) in breakages {
std::fs::write(&spec, format!("{original}{addition}")).expect("break the spec");
assert_ne!(project.gate(), 0, "the gate accepted bad code ({what})");
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("live-clone-copy");
let _ = std::fs::remove_dir_all(&clone);
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");
}