Files
vectorcraft/crates/ui-egui/src/tests/script_check.rs
T
dotCooCoo 2c0d09e079 Scripts: File › Scripts › Other Script… runs JavaScript files
File › Scripts › Other Script… picks a .js or .jsx file, shows a warning
that can be turned off in Preferences › General, and runs the script
against the open documents through the `vc` object. The new
`script.run` and `script.stop` commands run and stop a script over MCP.

- A run is one transaction. Each document it changed gets one undo step
  named Run Script. An uncaught error, a limit or a stop puts every
  document, the paint, the clipboard, menu state and preferences back.
- A command that fails inside a run, or whose result is too large to
  reach the script, is undone and throws an error the script can catch.
- Files a command wrote stay on disk and the report names them, also
  when the run is stopped from the quit question.
- While a script runs, other commands that change anything fail with a
  busy error; MCP reports it as JSON-RPC error -32003.
- In the app the run advances one slice per frame with a Stop button in
  the status bar. A run that adds a great many objects asks whether to
  continue; Stop and Escape end it.
- Commands that change state outside the documents, such as prefs.*,
  plugin.* and file.place.queue, are refused to scripts.

Docs: docs/mcp.md, docs/gaps.md, docs/roadmap.md,
docs/target-app-parity.md.
2026-11-09 18:35:41 -08:00

201 lines
9.1 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::Cmd("Other Script…", "ui.runScript", _)]),
"{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(), [crate::FilePick::filters([("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 == crate::FilePick::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}");
}
}