Files
vectorcraft/crates/ui-egui/src/tests_script_check.rs
T
dotCooCoo 3374d0778d Scripts: Check Script… reports what a .jsx script needs and does, without running it
VectorCraft does not run scripts written for the reference app, and
File › Scripts held only the disabled Other Script…. File › Scripts ›
Check Script… picks a .jsx, .jsxinc or .jsxbin file, reads it without
running any of it, and shows a report: what the script needs (the
script language's own objects, the document model, files, menu
commands, dialogs, or features only the reference app has), what it
would do outside the document, the menu-command strings it passes, the
document-model member chains and type names it uses, its directives,
the files it includes, and the problems that would stop it loading.
Copy Report copies the report as JSON. File › Open and drag-and-drop
show the report for those files too.

- vectorcraft-scriptcheck, a new Standalone crate with no
  dependencies, holds the checker: a lexer for ECMAScript 3 with E4X
  literals and the #name and //@name directive lines, and an analysis
  of the tokens that follows variables, function scopes and the files
  a script includes. It never recurses: open brackets, the levels of
  an XML literal and the function bodies being read are kept on stacks
  capped at 4,096. It reads at most 8 MiB of a file and 32 MiB with the
  included files, follows 64 included files 16 deep, looks up at most
  1,024 paths for them, keeps 1,000 entries in a list, tracks 1,000
  variables holding member chains and 1,000 holding File objects, and
  cuts echoed text to 120 characters. A compiled script is reported as
  compiled and isn't read.
- script.check (engine, a query) takes path, data or dataBase64. With a
  path and includes: true, it reads the files the #include lines name
  from inside the script's folder, through the automation read root,
  and when the script or one of those files assigns a member chain or a
  File object to a variable, the check treats the variable as holding
  it in every file. It doesn't follow absolute names, names, folders or
  links that lead out of the folder, names or folders longer than 4,096
  bytes, or JSINCLUDE, and it reads only regular files.
- ui.checkScript shows the report (dialog scriptCheck). File › Open
  lists Scripts (.jsx, .jsxinc, .jsxbin) and sends a script's path to
  script.check, which reads at most 8 MiB of it.

The language rules come from ECMA-262 3rd edition and ECMA-357 2nd
edition. The directive, platform and document-model root names come
from the public JavaScript Tools Guide and scripting guide pages that
catalog.rs cites by URL, and executeMenuCommand, doScript, loadAction,
unloadAction and selectTool come from the public forum posts cited
there; only names are used. Every script in the tests is written from
scratch with invented names.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-13 13:36:41 +07:00

201 lines
9.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File › Scripts › Check Script… (`ui.checkScript`): the report on a script written for the reference
//! app, read without running it, and its Copy Report; File › Open of `.jsx`, `.jsxinc` and `.jsxbin`
//! files.
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use crate::{VectorcraftApp, dialogs, io, menus, theme};
/// An app reading files from disk.
fn app() -> VectorcraftApp {
let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() };
VectorcraftApp::new(Session::new(), services)
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut out = ctx.run_ui(Default::default(), |ui| dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
/// A script with a little of everything the report shows.
const SCRIPT: &str = concat!(
"#target acme\n",
"#include 'lib/missing.jsxinc'\n",
"var doc = app.activeDocument;\n",
"app.executeMenuCommand('acmecopyall');\n",
"app.executeMenuCommand(name);\n",
"var f = new File(p); f.open('w');\n",
"if (x.typename == 'AcmeShape') {}\n",
"var s = 'open;\n",
);
fn report(app: &VectorcraftApp) -> &Value {
let d = app.ui.dialog.as_ref().expect("the report is shown");
assert_eq!(d.kind, dialogs::script_check::KIND);
&d.fields["report"]
}
#[test]
fn file_scripts_holds_check_script_above_other_script() {
let tree = menus::menu_tree();
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
let scripts = file.iter().find_map(|i| if let menus::Item::Sub("Scripts", items) = i { Some(items) } else { None }).unwrap();
assert!(
matches!(scripts.as_slice(), [menus::Item::Cmd("Check Script…", "ui.checkScript", _), menus::Item::Todo("Other Script…", _)]),
"{scripts:?}"
);
}
#[test]
fn check_script_shows_the_report_and_copy_report_copies_it() {
let mut app = app();
let p = json!({"dataBase64": vectorcraft_format::base64_encode(SCRIPT.as_bytes()), "name": "Tidy.jsx"});
let r = menus::run_ui_command(&mut app, "ui.checkScript", &p).unwrap().unwrap();
assert_eq!(report(&app), &r);
assert_eq!((r["name"].as_str(), r["kind"].as_str()), (Some("Tidy.jsx"), Some("script")));
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands"]));
// The window draws every section.
frame(&mut app);
assert_eq!(report(&app), &r);
// Copy Report (the button beside Close) copies the report as JSON and leaves the window open.
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
dialogs::confirm(&mut app).unwrap();
let copied: Value = serde_json::from_str(app.clipboard_out.as_deref().unwrap()).unwrap();
assert_eq!(copied, r);
assert!(app.ui.dialog.as_ref().unwrap().fields.get("discard").is_none());
assert_eq!(app.ui.status, "Copied the script report");
// Close closes it.
dialogs::cancel(&mut app);
assert!(app.ui.dialog.is_none());
// The script's text does as well as its bytes.
let by_text = menus::run_ui_command(&mut app, "ui.checkScript", &json!({"data": SCRIPT, "name": "Tidy.jsx"})).unwrap().unwrap();
assert_eq!(by_text, r);
}
#[test]
fn each_kind_of_file_draws_its_verdict() {
for (name, bytes, kind) in [
("a.jsxbin", b"anything".as_slice(), "compiled"),
("a.jsx", b"import a from 'a';\n".as_slice(), "module"),
("a.jsx", b"\0\0\0".as_slice(), "notText"),
("a.jsx", b"".as_slice(), "script"),
] {
let mut app = app();
io::open_bytes(&mut app, name, bytes, None).unwrap();
assert_eq!(report(&app)["kind"], kind, "{name}");
frame(&mut app);
assert!(app.ui.dialog.is_some());
}
}
#[test]
fn file_open_of_a_script_checks_it_with_what_it_includes() {
let dir = std::env::temp_dir().join(format!("vc-ui-script-check-{}", std::process::id()));
std::fs::create_dir_all(dir.join("lib")).unwrap();
std::fs::write(dir.join("main.jsx"), "#include 'lib/net.jsxinc'\n").unwrap();
std::fs::write(dir.join("lib/net.jsxinc"), "var s = new Socket();\n").unwrap();
let path = dir.join("main.jsx").to_string_lossy().into_owned();
let mut opened = app();
let reads = std::rc::Rc::new(std::cell::Cell::new(0));
let counted = reads.clone();
opened.services.read = Some(Box::new(move |p: &str| {
counted.set(counted.get() + 1);
std::fs::read(p).map_err(|e| e.to_string())
}));
io::open_path(&mut opened, &path).unwrap();
let r = report(&opened);
assert_eq!(r["includes"], json!([{"value": "lib/net.jsxinc", "line": 1, "status": "checked", "path": "lib/net.jsxinc"}]));
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
assert!(opened.session.documents().is_empty(), "no document opened");
// `script.check` reads the script up to its limit, and the app's reader, which reads a whole file,
// isn't asked.
assert_eq!(reads.get(), 0);
assert_eq!(opened.ui.recent_files.first(), Some(&path), "File › Open lists it in Open Recent Files");
// Check Script… with a path does the same; without one it asks for a script.
let mut app = app();
menus::run_ui_command(&mut app, "ui.checkScript", &json!({ "path": path })).unwrap().unwrap();
assert_eq!(report(&app)["includes"][0]["status"], "checked");
let asked = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let seen = asked.clone();
let picked = path.clone();
app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| {
seen.borrow_mut().push(pick.filters.clone());
Some(picked.clone())
}));
app.ui.dialog = None;
menus::run_ui_command(&mut app, "ui.checkScript", &json!({})).unwrap().unwrap();
assert_eq!(asked.borrow().as_slice(), [vec![("Scripts", &["jsx", "jsxinc", "jsxbin"][..])]]);
assert_eq!(report(&app)["name"], "main.jsx");
let _ = std::fs::remove_dir_all(dir);
}
/// Where a document opens off the UI thread (File › Open, Open Recent and drops on the desktop), a
/// script shows its report at once, and the app's reader isn't asked for it.
#[test]
fn a_script_opens_at_once_where_documents_open_off_the_ui_thread() {
let dir = std::env::temp_dir().join(format!("vc-ui-script-background-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("later.jsx");
std::fs::write(&path, "alert('later');\n").unwrap();
let path = path.to_string_lossy().into_owned();
let mut opened = app();
let reads = std::rc::Rc::new(std::cell::Cell::new(0));
let counted = reads.clone();
opened.services.read = Some(Box::new(move |p: &str| {
counted.set(counted.get() + 1);
std::fs::read(p).map_err(|e| e.to_string())
}));
assert_eq!(io::open_file(&mut opened, &path, true).unwrap(), Value::Null);
assert!(opened.opening.pending.is_empty(), "nothing is read off the UI thread");
assert_eq!(report(&opened)["name"], "later.jsx");
assert_eq!(reads.get(), 0);
assert_eq!(opened.ui.recent_files.first(), Some(&path));
let _ = std::fs::remove_dir_all(dir);
}
/// Check Script… asks for its script off the UI thread, and acts on no document: the answer shows the
/// report even when the document that was active closed meanwhile.
#[test]
fn check_script_asks_off_the_ui_thread_and_needs_no_document() {
let dir = std::env::temp_dir().join(format!("vc-ui-script-pick-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("picked.jsx");
std::fs::write(&path, "alert('picked');\n").unwrap();
let mut services = crate::Services { dialogs_are_sheets: false, ..Default::default() };
let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut services);
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
assert!(app.run("ui.checkScript", json!({})).is_err(), "nothing yet: the dialog is open");
assert!(
matches!(shown.requests().as_slice(), [crate::picks::PickRequest::Open(pick)] if pick.filters == [("Scripts", &["jsx", "jsxinc", "jsxbin"][..])])
);
app.run("file.close", json!({})).unwrap();
shown.answer(&[&path.to_string_lossy()]);
crate::picks::poll(&mut app, &egui::Context::default());
assert_eq!(report(&app)["name"], "picked.jsx", "{}", app.ui.status);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn every_id_a_report_holds_has_a_label() {
use dialogs::script_check::{need_label, outside_label, problem_label, status_label};
use vectorcraft_engine::cmd::scripts::{IncludeStatus, Need, OUTSIDE, PROBLEMS};
for need in Need::ALL {
assert!(need_label(need.id()).is_some(), "{need:?}");
}
for status in IncludeStatus::ALL {
assert!(status_label(status.id()).is_some(), "{status:?}");
}
for id in OUTSIDE {
assert!(outside_label(id).is_some(), "{id}");
}
for code in PROBLEMS {
assert!(problem_label(code).is_some(), "{code}");
}
}