mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
335 lines
17 KiB
Rust
335 lines
17 KiB
Rust
//! A script's preprocessor lines: `#include` and `//@include` in place, the folders they are looked
|
|
//! for in, the run's limits and scope, the other lines, `$.evalFile`, `$.fileName`, and errors at the
|
|
//! line of the file they are in.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::script::TEST_JSINCLUDE;
|
|
use crate::*;
|
|
|
|
/// A fresh folder for test `name`, with a `main` folder inside it for the script.
|
|
fn folder(name: &str) -> PathBuf {
|
|
let base = std::env::temp_dir().join(format!("vc-script-include-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
std::fs::create_dir_all(base.join("main")).unwrap();
|
|
base
|
|
}
|
|
|
|
fn write(path: &Path, text: &str) {
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir).unwrap();
|
|
}
|
|
std::fs::write(path, text).unwrap();
|
|
}
|
|
|
|
/// Runs `main/main.jsx` in `base`, with the text `text`.
|
|
fn run(base: &Path, text: &str) -> Result<Value> {
|
|
let main = base.join("main/main.jsx");
|
|
write(&main, text);
|
|
Session::new().execute("script.run", &json!({"path": main.to_string_lossy()}))
|
|
}
|
|
|
|
/// The report of the run that failed with `e`.
|
|
fn failed(base: &Path, text: &str) -> Value {
|
|
let mut s = Session::new();
|
|
let main = base.join("main/main.jsx");
|
|
write(&main, text);
|
|
let e = s.execute("script.run", &json!({"path": main.to_string_lossy()}));
|
|
assert!(e.is_err(), "the run failed: {e:?}");
|
|
s.script_report().unwrap().clone()
|
|
}
|
|
|
|
#[test]
|
|
fn script_include_sibling_runs_in_place() {
|
|
let base = folder("sibling");
|
|
write(&base.join("main/lib.jsxinc"), "var b = a + 1;");
|
|
write(&base.join("main/two.jsxinc"), "b = b * 10;\r\n");
|
|
let r = run(&base, "var a = 1;\n #include \"lib.jsxinc\"\n//@include 'two.jsxinc'\nvc.log(a + b);").unwrap();
|
|
assert_eq!(r["log"], json!([21]));
|
|
// A file may be included again once its earlier inclusion has ended.
|
|
let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include \"three.jsxinc\"\nvc.log(n);");
|
|
assert!(r.is_err(), "three.jsxinc is missing");
|
|
write(&base.join("main/three.jsxinc"), "n++;");
|
|
let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include three.jsxinc\nvc.log(n);").unwrap();
|
|
assert_eq!(r["log"], json!([2]));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_nested_include_uses_including_folder() {
|
|
let base = folder("nested");
|
|
write(&base.join("main/sub/one.jsxinc"), "#include \"two.jsxinc\"\nvar one = two + 1;");
|
|
write(&base.join("main/sub/two.jsxinc"), "var two = 2;");
|
|
write(&base.join("main/two.jsxinc"), "var two = 200;");
|
|
let r = run(&base, "#include \"sub/one.jsxinc\"\nvc.log(one);").unwrap();
|
|
assert_eq!(r["log"], json!([3]), "two.jsxinc beside one.jsxinc, not beside the script");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_missing_include_reports_origin() {
|
|
let base = folder("missing");
|
|
let report = failed(&base, "vc.log(1);\n\t#include \"nope.jsxinc\"\n");
|
|
assert_eq!(report["kind"], "thrown");
|
|
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(2)));
|
|
assert!(report["message"].as_str().unwrap().contains("include file not found: nope.jsxinc"), "{report}");
|
|
assert_eq!(report["log"], json!([]), "nothing ran before the include was read");
|
|
assert!(report.get("file").is_none(), "the line is the script's own");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_includepath_searches_in_order() {
|
|
let base = folder("includepath");
|
|
write(&base.join("main/a/x.jsxinc"), "var x = 'a';");
|
|
write(&base.join("main/b/x.jsxinc"), "var x = 'b';");
|
|
write(&base.join("main/b/y.jsxinc"), "var y = 'b';");
|
|
let r = run(&base, "#includepath \"a; b\"\n#include \"x.jsxinc\"\n#include \"y.jsxinc\"\nvc.log(x + y + $.includePath);").unwrap();
|
|
assert_eq!(r["log"], json!(["aba;b"]));
|
|
// A later #includepath replaces the list.
|
|
let report = failed(&base, "#includepath \"b\"\n#includepath \"a\"\n#include \"y.jsxinc\"");
|
|
assert!(report["message"].as_str().unwrap().contains("include file not found: y.jsxinc"), "{report}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_include_cycle_and_depth_stop() {
|
|
let base = folder("cycle");
|
|
write(&base.join("main/a.jsxinc"), "#include \"b.jsxinc\"");
|
|
write(&base.join("main/b.jsxinc"), "\n#include \"a.jsxinc\"");
|
|
let report = failed(&base, "#include \"a.jsxinc\"");
|
|
assert!(report["message"].as_str().unwrap().contains("include cycle: a.jsxinc"), "{report}");
|
|
assert_eq!(report["line"], 2);
|
|
assert!(report["file"].as_str().unwrap().ends_with("b.jsxinc"), "{report}");
|
|
// A script that includes itself.
|
|
let report = failed(&base, "#include \"main.jsx\"");
|
|
assert!(report["message"].as_str().unwrap().contains("include cycle"), "{report}");
|
|
for i in 0..20 {
|
|
write(&base.join(format!("main/d{i}.jsxinc")), &format!("#include \"d{}.jsxinc\"", i + 1));
|
|
}
|
|
let report = failed(&base, "#include \"d0.jsxinc\"");
|
|
assert_eq!(report["kind"], "limit");
|
|
assert!(report["message"].as_str().unwrap().contains("include depth limit (16) reached"), "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with("d15.jsxinc"), "the 17th level is refused: {report}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
/// Makes `link` lead to the folder `target`: a junction on Windows, a symbolic link elsewhere.
|
|
/// `false` when the system does not allow it here.
|
|
fn link_folder(target: &Path, link: &Path) -> bool {
|
|
#[cfg(windows)]
|
|
{
|
|
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().is_ok_and(|o| o.status.success());
|
|
made && std::fs::canonicalize(link).ok() == std::fs::canonicalize(target).ok()
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::fs::symlink(target, link).is_ok()
|
|
}
|
|
#[cfg(not(any(unix, windows)))]
|
|
{
|
|
let _ = (target, link);
|
|
false
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn script_include_link_out_of_the_folder_is_refused() {
|
|
let base = folder("junction");
|
|
write(&base.join("outside/x.jsxinc"), "var leaked = 1;");
|
|
// `mklink` reads a `/` in its paths as a switch, so each name is joined on its own.
|
|
if !link_folder(&base.join("outside"), &base.join("main").join("away")) {
|
|
eprintln!("skipped: a folder link can't be made here");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
return;
|
|
}
|
|
assert!(std::fs::canonicalize(base.join("main/away/x.jsxinc")).unwrap().starts_with(std::fs::canonicalize(base.join("outside")).unwrap()));
|
|
let report = failed(&base, "#include \"away/x.jsxinc\"\nvc.log(leaked);");
|
|
assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}");
|
|
let report = failed(&base, "$.evalFile('away/x.jsxinc');");
|
|
assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_include_parent_component_refused() {
|
|
let base = folder("parent");
|
|
write(&base.join("outside.jsxinc"), "var leaked = 1;");
|
|
write(&base.join("main/sub/x.jsxinc"), "var x = 1;");
|
|
let report = failed(&base, "#include \"../outside.jsxinc\"");
|
|
assert!(report["message"].as_str().unwrap().contains("script file read refused: ../outside.jsxinc"), "{report}");
|
|
// Refused even where the path would come back inside the folder.
|
|
let report = failed(&base, "#include \"sub/../sub/x.jsxinc\"");
|
|
assert!(report["message"].as_str().unwrap().contains("script file read refused"), "{report}");
|
|
let report = failed(&base, "#includepath \"..\"\n#include \"outside.jsxinc\"");
|
|
assert!(report["message"].as_str().unwrap().contains("script file read refused: outside.jsxinc"), "{report}");
|
|
let absolute = base.join("outside.jsxinc").to_string_lossy().replace('\\', "/");
|
|
let report = failed(&base, &format!("#include \"{absolute}\""));
|
|
assert!(report["message"].as_str().unwrap().contains("script file read refused"), "an absolute path outside: {report}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_jsinclude_outside_grants_ignored() {
|
|
let base = folder("jsinclude");
|
|
write(&base.join("outside/x.jsxinc"), "var x = 'outside';");
|
|
write(&base.join("main/lib/y.jsxinc"), "var y = 'lib';");
|
|
TEST_JSINCLUDE.with(|t| *t.borrow_mut() = Some(vec![base.join("outside"), base.join("main/lib")]));
|
|
let report = failed(&base, "#include \"x.jsxinc\"");
|
|
let r = run(&base, "#include \"y.jsxinc\"\nvc.log(y);");
|
|
TEST_JSINCLUDE.with(|t| *t.borrow_mut() = None);
|
|
assert!(report["message"].as_str().unwrap().contains("include file not found: x.jsxinc"), "passed over, not refused: {report}");
|
|
assert_eq!(r.unwrap()["log"], json!(["lib"]), "a JSINCLUDE folder inside the script's folder is searched");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_target_other_app_does_not_route() {
|
|
let mut s = Session::new();
|
|
let r = s.execute("script.run", &json!({"source": "#target someapp-12\n#target \"other\"\n//@target x\n#strict off\nvc.log(1);"})).unwrap();
|
|
assert_eq!(r["log"], json!([1]));
|
|
for (source, message) in [
|
|
("#target\nvc.log(1);", "#target needs a value"),
|
|
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
|
|
("#engine \"main\"", "unsupported script directive: #engine"),
|
|
("#strict on", "unsupported strict checking: #strict on"),
|
|
("#strict maybe", "#strict takes on or off"),
|
|
("#frobnicate 1", "unknown script directive: #frobnicate"),
|
|
("#include \"x.jsxinc", "unterminated quote in #include"),
|
|
("#include \"x.jsxinc\"", "script file read refused: x.jsxinc"),
|
|
] {
|
|
let e = s.execute("script.run", &json!({"source": format!("vc.log(0);\n{source}")})).unwrap_err().to_string();
|
|
assert!(e.contains(message), "{source}: {e}");
|
|
assert_eq!(s.script_report().unwrap()["line"], 2, "{source}");
|
|
assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}");
|
|
}
|
|
// An unknown `//@` name stays a comment.
|
|
assert_eq!(s.execute("script.run", &json!({"source": "//@frobnicate 1\nvc.log(2);"})).unwrap()["log"], json!([2]));
|
|
}
|
|
|
|
#[test]
|
|
fn script_name_comes_from_the_script_line() {
|
|
let mut s = Session::new();
|
|
let r = s.execute("script.run", &json!({"source": "#script \"First\"\n#script Second\nvc.log($.fileName);"})).unwrap();
|
|
assert_eq!((r["name"].as_str(), r["log"].clone()), (Some("Second"), json!([""])));
|
|
let r = s.execute("script.run", &json!({"source": "#script Second\n", "name": "given.js"})).unwrap();
|
|
assert_eq!(r["name"], "given.js", "a name given to script.run wins");
|
|
let base = folder("name");
|
|
write(&base.join("main/lib.jsxinc"), "#script Inner\n");
|
|
let r = run(&base, "#include \"lib.jsxinc\"").unwrap();
|
|
assert_eq!(r["name"], "main.jsx", "an included file's #script line does not rename the run");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_strict_on_is_refused() {
|
|
let mut s = Session::new();
|
|
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
|
|
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
|
|
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
|
|
}
|
|
|
|
#[test]
|
|
fn script_eval_file_returns_last_value_and_shares_globals() {
|
|
let base = folder("evalfile");
|
|
write(&base.join("main/sub/e.jsx"), "var inner = $.fileName;\nfunction h(x) { return x * g; }\n#include \"f.jsxinc\"\ng + f;");
|
|
write(&base.join("main/sub/f.jsxinc"), "var f = 40;");
|
|
write(&base.join("main/empty.jsx"), "");
|
|
let r = run(
|
|
&base,
|
|
"var g = 2;\nvar r = $.evalFile('sub/e.jsx', 1000);\nvc.log(r);\nvc.log(h(3));\nvc.log(/e\\.jsx$/.test(inner));\nvc.log(/main\\.jsx$/.test($.fileName));\nvc.log($.evalFile('empty.jsx') === undefined);",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["log"], json!([42, 6, true, true, true]));
|
|
// Its errors can be caught; a file is evaluated again after it has returned.
|
|
let r = run(
|
|
&base,
|
|
"var g = 1; var out = [];\ntry { $.evalFile(''); } catch (e) { out.push(e.name); }\ntry { $.evalFile('nope.jsx'); } catch (e) { out.push(String(e)); }\ntry { $.evalFile('sub/e.jsx', -1); } catch (e) { out.push(e.name); }\ntry { $.evalFile('sub/e.jsx', Infinity); } catch (e) { out.push(e.name); }\nout.push($.evalFile('sub/e.jsx') + $.evalFile('sub/e.jsx'));\nvc.log(out);",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["log"], json!([["TypeError", "Error: include file not found: nope.jsx", "RangeError", "TypeError", 82]]));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_eval_file_outside_scope_refused() {
|
|
let base = folder("evaloutside");
|
|
write(&base.join("outside.jsx"), "1;");
|
|
let absolute = base.join("outside.jsx").to_string_lossy().replace('\\', "/");
|
|
let script = format!(
|
|
"var out = [];\ntry {{ $.evalFile('../outside.jsx'); }} catch (e) {{ out.push(String(e)); }}\ntry {{ $.evalFile('{absolute}'); }} catch (e) {{ out.push(String(e)); }}\nvc.log(out);"
|
|
);
|
|
let r = run(&base, &script).unwrap();
|
|
let out = r["log"][0].as_array().unwrap();
|
|
assert_eq!(out.len(), 2);
|
|
for e in out {
|
|
assert!(e.as_str().unwrap().starts_with("Error: script file read refused"), "{e}");
|
|
}
|
|
// A script given as text has no folder to read from.
|
|
let e = Session::new().execute("script.run", &json!({"source": "$.evalFile('x.jsx');"})).unwrap_err().to_string();
|
|
assert!(e.contains("script file read refused: x.jsx"), "{e}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn script_directives_in_strings_and_block_comments_are_inert() {
|
|
let mut s = Session::new();
|
|
let source = "#target x\nvar a = '#include \"nope.jsxinc\"';\n/*\n#include \"nope.jsxinc\"\n//@include \"nope.jsxinc\"\n*/\nvar b = 1; // #include \"nope.jsxinc\"\nvar c = 2; #include\nvc.log(a.length + b);";
|
|
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
|
|
// `#` after code on a line is not a preprocessor line: the script has a syntax error there.
|
|
assert!(e.contains("line 8"), "{e}");
|
|
let source = source.replace("var c = 2; #include\n", "");
|
|
assert_eq!(s.execute("script.run", &json!({"source": source})).unwrap()["log"], json!([23]));
|
|
}
|
|
|
|
#[test]
|
|
fn script_included_error_has_local_file_and_line() {
|
|
let base = folder("errors");
|
|
write(&base.join("main/lib.jsxinc"), "var x = 1;\n\n throw new Error('boom');\n");
|
|
write(&base.join("main/bad.jsxinc"), "var y = 1;\nvar = 2;\n");
|
|
write(&base.join("main/five.jsxinc"), "1;\n2;\n3;\n4;\n5;");
|
|
write(&base.join("main/sub/thrower.jsx"), "\n\nthrow new Error('deep');");
|
|
let mut s = Session::new();
|
|
let report = failed(&base, "var z = 0;\n#include \"lib.jsxinc\"");
|
|
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(5)), "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{report}");
|
|
let report = failed(&base, "#include \"bad.jsxinc\"");
|
|
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with("bad.jsxinc"), "{report}");
|
|
// A line of the script after an include keeps its own number.
|
|
let report = failed(&base, "#include \"five.jsxinc\"\n throw new Error('after');");
|
|
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(3)), "{report}");
|
|
assert!(report.get("file").is_none(), "{report}");
|
|
let report = failed(&base, "#include \"five.jsxinc\"\nvar = 1;");
|
|
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
|
|
// An included file that ends in a carriage return does not join the script's next line ending.
|
|
write(&base.join("main/cr.jsxinc"), "1;\r2;\r");
|
|
let report = failed(&base, "#include \"cr.jsxinc\"\n\n throw new Error('after');");
|
|
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(3)), "{report}");
|
|
assert!(report.get("file").is_none(), "{report}");
|
|
let report = failed(&base, "#include \"cr.jsxinc\"\r\nvar = 1;");
|
|
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
|
|
// An uncaught error in a file $.evalFile ran is at that file's line.
|
|
let report = failed(&base, "var a = 1;\n$.evalFile('sub/thrower.jsx');");
|
|
assert_eq!(report["line"], 3, "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with("thrower.jsx"), "{report}");
|
|
// So is a file $.evalFile could not run: a syntax error, or a bad preprocessor line.
|
|
write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;");
|
|
write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1");
|
|
for (file, line) in [("broken.jsx", 3), ("directive.jsx", 2)] {
|
|
let report = failed(&base, &format!("var a = 1;\n$.evalFile('sub/{file}');"));
|
|
assert_eq!(report["line"], line, "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with(file), "{report}");
|
|
}
|
|
// The console's error line names the file.
|
|
let main = base.join("main/main.jsx");
|
|
write(&main, "#include \"lib.jsxinc\"");
|
|
assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err());
|
|
let console = s.execute("script.console", &json!({})).unwrap();
|
|
let error = console["lines"].as_array().unwrap().iter().find(|l| l["kind"] == "error").unwrap()["text"].as_str().unwrap().to_string();
|
|
assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|