mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
566 lines
31 KiB
Rust
566 lines
31 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 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);
|
|
}
|
|
|
|
/// The script's `ro`, with a read-only `x`.
|
|
const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n";
|
|
|
|
#[test]
|
|
fn strict_on_runs_and_read_only_write_throws() {
|
|
let mut s = Session::new();
|
|
for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] {
|
|
let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);");
|
|
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
|
|
assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}");
|
|
let report = s.script_report().unwrap();
|
|
assert_eq!(report["kind"], "thrown", "{head}");
|
|
assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write");
|
|
let line = head.lines().count() as u64 + 4;
|
|
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}");
|
|
}
|
|
// The error can be caught, and every kind of assignment is checked.
|
|
let source = format!(
|
|
"#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);"
|
|
);
|
|
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
|
assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1]));
|
|
// Without the line, or with `#strict off` last, the write is ignored as before.
|
|
for head in ["", "#strict off\n", "#strict on\n#strict off\n"] {
|
|
let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap();
|
|
assert_eq!(r["log"], json!([1]), "{head}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn strict_on_rejects_getter_only_and_nonextensible_additions() {
|
|
let mut s = Session::new();
|
|
let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\
|
|
var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\
|
|
t(function () { g.v = 2; });\n\
|
|
t(function () { Object.preventExtensions({}).n = 1; });\n\
|
|
t(function () { Object.seal({a: 1}).n = 1; });\n\
|
|
t(function () { Object.freeze({a: 1}).a = 2; });\n\
|
|
t(function () { vc.extra = 1; });\n\
|
|
var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\
|
|
vc.log(out);\nvc.log(open.n);\nvc.log(set.got);";
|
|
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
|
assert_eq!(
|
|
r["log"],
|
|
json!([
|
|
[
|
|
"TypeError: cannot assign to property 'v': it has only a getter",
|
|
"TypeError: cannot assign to property 'n': the object is not extensible",
|
|
"TypeError: cannot assign to property 'n': the object is not extensible",
|
|
"TypeError: cannot assign to property 'a': it is read-only",
|
|
"TypeError: cannot assign to property 'extra': the object is not extensible",
|
|
"wrote",
|
|
"wrote",
|
|
"wrote"
|
|
],
|
|
1,
|
|
3
|
|
])
|
|
);
|
|
let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap();
|
|
assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused");
|
|
}
|
|
|
|
#[test]
|
|
fn strict_on_does_not_enable_es5_early_errors() {
|
|
let mut s = Session::new();
|
|
let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\
|
|
vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());";
|
|
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
|
assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6]));
|
|
// 'use strict' still makes the code strict mode code.
|
|
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string();
|
|
assert!(e.contains("ReferenceError"), "{e}");
|
|
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string();
|
|
assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn strict_on_in_include_controls_expanded_run() {
|
|
let base = folder("strict");
|
|
write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;");
|
|
write(&base.join("main/off.jsxinc"), "//@strict off\n");
|
|
write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n"));
|
|
// An included file's line sets the checks for the script's own code too.
|
|
let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;"));
|
|
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
|
assert_eq!(report["line"], 4, "{report}");
|
|
assert!(report.get("file").is_none(), "{report}");
|
|
// A write in an included file is reported at its own line.
|
|
let report = failed(&base, "#strict on\n#include \"writes.jsxinc\"");
|
|
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
|
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}");
|
|
// The last line of the expanded run wins, wherever it is.
|
|
let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap();
|
|
assert_eq!(r["log"], json!([1]));
|
|
let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;"));
|
|
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn strict_mode_in_eval_file_is_scoped() {
|
|
let base = folder("strict-eval");
|
|
write(
|
|
&base.join("main/on.jsx"),
|
|
&format!(
|
|
"#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n"
|
|
),
|
|
);
|
|
write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';"));
|
|
write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';"));
|
|
// A file's own `#strict on` holds for its code, its functions included, and not the caller's.
|
|
let r = run(
|
|
&base,
|
|
"$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]]));
|
|
// A file without the line takes the caller's checks, and its own `#strict off` turns them off.
|
|
let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap();
|
|
assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks");
|
|
let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');");
|
|
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}");
|
|
assert_eq!(report["line"], 3, "{report}");
|
|
assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"]));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// Pumps the run in the app until it ends or waits at a dialog (`None`).
|
|
fn pump(s: &mut Session) -> Option<Result<Value>> {
|
|
for _ in 0..10_000 {
|
|
if s.script_dialog().is_some() {
|
|
return None;
|
|
}
|
|
if let Some(r) = s.script_pump() {
|
|
return Some(r);
|
|
}
|
|
}
|
|
panic!("the run neither ended nor waited");
|
|
}
|
|
|
|
#[test]
|
|
fn script_dialogs_in_included_and_evaluated_files() {
|
|
let base = folder("dialogs");
|
|
write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\n");
|
|
write(&base.join("main/ev.jsx"), "alert('ev'); 7");
|
|
let text = "#include \"ask.jsxinc\"\nvar m = '';\ntry { m = $.evalFile('ev.jsx'); } catch (e) { m = e.message; }\nvc.log([a, m]);";
|
|
let main = base.join("main/main.jsx");
|
|
write(&main, text);
|
|
let path = main.to_string_lossy().to_string();
|
|
// In the app, an included file's dialog waits as the script's own does; one in a file
|
|
// $.evalFile runs cannot wait there and throws an error the script can catch.
|
|
let mut s = Session::new();
|
|
s.script_frames = true;
|
|
assert_eq!(s.execute("script.run", &json!({"path": path})).unwrap()["running"], true);
|
|
assert!(pump(&mut s).is_none());
|
|
assert_eq!(s.script_dialog().unwrap().message, "inc");
|
|
assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true)));
|
|
let r = pump(&mut s).unwrap().unwrap();
|
|
let m = r["log"][0][1].as_str().unwrap_or_default();
|
|
assert_eq!(r["log"][0][0], true, "{r}");
|
|
assert!(m.contains("alert") && m.contains("$.evalFile"), "the error names the call and why: {r}");
|
|
assert_eq!(r["dialogCount"], 1, "{r}");
|
|
// A run with nobody to ask takes both answers in order.
|
|
let mut s = Session::new();
|
|
let answers = json!([{"kind": "confirm", "value": false}, {"kind": "alert", "value": "ok"}]);
|
|
let r = s.execute("script.run", &json!({"path": path, "answers": answers})).unwrap();
|
|
assert_eq!((r["log"].clone(), r["dialogCount"].clone()), (json!([[false, 7]]), json!(2)), "{r}");
|
|
// An error in an included file after its dialog is at that file's line, and the report keeps
|
|
// both the file and the dialog.
|
|
write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\nif (a) throw new Error('after');\n");
|
|
let mut s = Session::new();
|
|
s.script_frames = true;
|
|
s.execute("script.run", &json!({"path": path})).unwrap();
|
|
assert!(pump(&mut s).is_none());
|
|
assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true)));
|
|
assert!(pump(&mut s).unwrap().is_err());
|
|
let report = s.script_report().unwrap();
|
|
assert_eq!(report["line"], 2, "{report}");
|
|
assert!(report["file"].as_str().unwrap().ends_with("ask.jsxinc"), "{report}");
|
|
assert_eq!(report["dialogs"][0]["answer"], true, "{report}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
/// The line, column and file name of a failed run's report, and its console error line (the only
|
|
/// line of a run without output that starts with the script's name).
|
|
fn place(report: &Value) -> (u64, u64, Option<&str>, &str) {
|
|
let file = report.get("file").map(|f| f.as_str().unwrap().rsplit(['/', '\\']).next().unwrap());
|
|
let lines = report["console"]["lines"].as_array().unwrap();
|
|
let error = lines.iter().filter_map(Value::as_str).find(|l| l.starts_with("main.jsx")).unwrap();
|
|
(report["line"].as_u64().unwrap(), report["column"].as_u64().unwrap(), file, error)
|
|
}
|
|
|
|
#[test]
|
|
fn script_eval_file_error_caught_and_thrown_again_is_at_the_new_throw() {
|
|
let base = folder("rethrow");
|
|
write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;");
|
|
write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1");
|
|
write(&base.join("main/sub/rethrow.jsx"), "var r = 1;\n throw saved;");
|
|
write(&base.join("main/sub/outer.jsx"), "var q = 1;\n$.evalFile('broken.jsx');");
|
|
write(&base.join("main/sub/outer2.jsx"), "try { $.evalFile('broken.jsx'); } catch (e) {\n\n throw e;\n}");
|
|
let caught = "var saved;\ntry { $.evalFile('sub/broken.jsx'); } catch (e) { saved = e; }\n";
|
|
let cases = [
|
|
// Thrown again in the script, or in another file $.evalFile runs.
|
|
(format!("{caught}var b = 2;\n throw saved;"), (4, 3, None)),
|
|
(format!("{caught}$.evalFile('sub/rethrow.jsx');"), (2, 3, Some("rethrow.jsx"))),
|
|
// A new error whose message holds the failure's text, or is the same text.
|
|
(format!("{caught}throw new Error(saved.message);"), (3, 1, None)),
|
|
(format!("{caught}throw new SyntaxError(saved.message);"), (3, 1, None)),
|
|
// A second failure, caught, does not move the first one.
|
|
(format!("{caught}try {{ $.evalFile('sub/directive.jsx'); }} catch (e) {{}}\n throw saved;"), (4, 2, None)),
|
|
// A second failure, uncaught, is at its own file's place.
|
|
(format!("{caught}$.evalFile('sub/directive.jsx');"), (2, 1, Some("directive.jsx"))),
|
|
// An uncaught failure is at the file's place, through a finally block and from a nested file.
|
|
("var a = 1;\ntry { $.evalFile('sub/broken.jsx'); } finally { a = 2; }".to_string(), (3, 5, Some("broken.jsx"))),
|
|
("$.evalFile('sub/outer.jsx');".to_string(), (3, 5, Some("broken.jsx"))),
|
|
// Caught in a nested file and thrown again there.
|
|
("$.evalFile('sub/outer2.jsx');".to_string(), (3, 5, Some("outer2.jsx"))),
|
|
// A failure caught in a run after one that failed with it, and a different error.
|
|
("try { $.evalFile('sub/broken.jsx'); } catch (e) {}\nthrow new Error('other');".to_string(), (2, 1, None)),
|
|
];
|
|
let mut s = Session::new();
|
|
let main = base.join("main/main.jsx");
|
|
for (text, (line, column, file)) in cases {
|
|
write(&main, &text);
|
|
assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err(), "{text}");
|
|
let report = s.script_report().unwrap().clone();
|
|
let (l, c, f, error) = place(&report);
|
|
assert_eq!((report["kind"].as_str(), l, c, f), (Some("thrown"), line, column, file), "{text}\n{report}");
|
|
let at = match file {
|
|
Some(f) => format!("{f}:{line}:{column}: "),
|
|
None => format!("main.jsx:{line}:{column}: "),
|
|
};
|
|
assert!(error.starts_with("main.jsx") && error.contains(&at), "{error}");
|
|
}
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|