Files
vectorcraft/crates/ui-egui/src/tests/scripts_menu.rs
T
dotCooCoo 52efba54aa Scripts: File › Scripts lists the Scripts folder, subfolders as submenus
File › Scripts now lists the .js and .jsx files of the Scripts folder next
to the preferences, each named after its file without the extension, with
each subfolder as a submenu, above Check Script… and Other Script…. The
help page describes this listing:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html

Choosing a listed script runs it through script.run without the warning
Other Script… shows. script.listInstalled returns the same listing, and
ui.runInstalledScript {path} runs one entry.

VectorCraft's own rules: the folder is read off the UI thread when the
app starts, when its window becomes active and when the File menu of the
window's menu bar opens; only names are read; links, junctions, hidden
and system entries are left out and checked again before a run; one read
stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms
and the menu then notes that some scripts were not listed. Under an
automation read root, script.listInstalled and ui.menu.list leave out a
folder outside the root.

#1183 (LloydNicholson) also proposed a Scripts menu listing a folder.
2026-11-10 17:46:36 -08:00

330 lines
14 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 lists the Scripts folder: its scripts and subfolders above Check Script… and
//! Other Script…, names shown as they are, a listed script run without the warning, a script that
//! is gone, the notes, the macOS menu bar and the reads of the folder.
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use crate::menus::{self, Entry, Item};
use crate::native_menu::{self, Node};
use crate::{VectorcraftApp, script_runs, scripts_menu};
/// A fresh folder for test `name`, holding an empty `Scripts` folder.
fn folder(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vc-scripts-menu-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Scripts")).unwrap();
dir
}
/// An app with a document whose Scripts folder is `dir`'s.
fn app(dir: &std::path::Path) -> VectorcraftApp {
let mut session = Session::new();
session.attach_scripts_dir(dir.join("Scripts"));
let mut app = VectorcraftApp::new(session, crate::Services::default());
app.run("file.new", json!({})).unwrap();
app
}
/// Reads the folder again and waits for the listing.
fn read(app: &VectorcraftApp) {
scripts_menu::refresh(app, None);
scripts_menu::wait();
}
/// Writes the script `name` (`/` between its folders) in `dir`'s Scripts folder; returns its path
/// as a listing gives it.
fn write(dir: &std::path::Path, name: &str, text: &str) -> String {
let path = name.split('/').fold(dir.join("Scripts"), |path, part| path.join(part));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path.to_string_lossy().into_owned()
}
/// File › Scripts' items.
fn scripts() -> Vec<Item> {
let (_, file) = menus::menu_tree().into_iter().find(|(title, _)| *title == "File").unwrap();
file.into_iter()
.find_map(|item| match item {
Item::Sub("Scripts", items) => Some(items),
_ => None,
})
.unwrap()
}
/// Menu items as text: a command's label, `label/[…]` for a submenu, `(text)` for a note and `-`
/// for a separator.
fn shape(items: &[Item]) -> String {
let one = |item: &Item| match item {
Item::Cmd(label, ..) | Item::Todo(label, _) => label.to_string(),
Item::Sub(label, items) => format!("{label}/[{}]", shape(items)),
Item::Header(text) => format!("({text})"),
Item::Sep => "-".to_string(),
};
items.iter().map(one).collect::<Vec<_>>().join(", ")
}
fn objects(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum()
}
fn finish(app: &mut VectorcraftApp) {
for _ in 0..10_000 {
if !app.session.script_running() {
return;
}
script_runs::poll(app);
}
panic!("the script did not end");
}
const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10}); vc.run('shape.rectangle', {x:20,y:0,width:10,height:10});";
#[test]
fn the_folders_scripts_are_listed_above_check_script_and_other_script() {
let dir = folder("listed");
let app = app(&dir);
assert_eq!(shape(&scripts()), "Check Script…, Other Script…", "before the first listing");
read(&app);
assert_eq!(shape(&scripts()), "Check Script…, Other Script…", "an empty folder");
let draw = write(&dir, "Draw Two.jsx", DRAW);
write(&dir, "Export/All Artboards.js", "");
write(&dir, "notes.txt", "");
read(&app);
let items = scripts();
assert_eq!(shape(&items), "Draw Two, Export/[All Artboards], -, Check Script…, Other Script…");
assert!(matches!(&items[0], Item::Cmd(_, scripts_menu::COMMAND, p) if *p == json!({ "path": draw })), "{:?}", items[0]);
// A script added to the folder is listed by the next read, with no restart.
write(&dir, "Added.js", "");
assert!(shape(&scripts()).starts_with("Draw Two"), "the menus keep the last listing");
read(&app);
assert!(shape(&scripts()).starts_with("Added, Draw Two"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_listed_script_runs_without_the_warning_as_one_undo_step() {
let dir = folder("run");
let mut app = app(&dir);
write(&dir, "Dessin (été) #1.JSX", DRAW);
read(&app);
let items = scripts();
let Item::Cmd(label, id, p) = &items[0] else { panic!("{:?}", items[0]) };
let undo = app.session.doc().unwrap().history.undo.len();
assert!(app.session.prefs.show_external_script_warning);
let (id, p) = menus::click_target(label, id, p);
menus::invoke(&mut app, &id, p);
assert!(app.ui.dialog.is_none(), "no warning for a script of the folder");
assert!(app.session.script_running());
finish(&mut app);
assert_eq!(objects(&app), 2);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(app.ui.status, "Ran Dessin (été) #1.JSX");
assert!(app.session.prefs.show_external_script_warning, "Other Script… still warns");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_script_runs_its_own_file_whatever_its_name_ends_with() {
let dir = folder("names");
let mut app = app(&dir);
// A label that ends like a menu item that asks for its parameters first.
let dots = write(&dir, "Draw….js", DRAW);
write(&dir, "Draw.js", "vc.run('shape.rectangle', {x:0,y:0,width:1,height:1});");
read(&app);
let items = scripts();
assert_eq!(shape(&items[..2]), "Draw, Draw…");
let Item::Cmd(label, id, p) = &items[1] else { panic!("{:?}", items[1]) };
assert_eq!(menus::click_target(label, id, p), (scripts_menu::COMMAND.to_string(), json!({ "path": dots })));
menus::run_ui_command(&mut app, id, p).unwrap().unwrap();
finish(&mut app);
assert_eq!(objects(&app), 2, "the file the item names ran, not its neighbor");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn script_and_folder_names_are_shown_as_they_are_in_every_language() {
let dir = folder("language");
let app = app(&dir);
write(&dir, "Layers.js", "");
write(&dir, "Export/Layers.jsx", "");
read(&app);
let de = crate::i18n::Lang::from_code("de").unwrap();
assert!(crate::i18n::tr(de, "Layers") != "Layers" && crate::i18n::tr(de, "Export") != "Export", "German translates both words");
crate::i18n::set_current(de);
let items = scripts();
let labels: Vec<String> = items
.iter()
.filter_map(|item| match menus::entry(&app, item)? {
Entry::Sub(label, _) => Some(label.to_string()),
Entry::Item(row) => Some(row.label.to_string()),
_ => None,
})
.collect();
crate::i18n::set_current(crate::i18n::Lang::EN);
assert_eq!(labels[..2], ["Export", "Layers"]);
assert_ne!(labels[2], "Check Script…", "the fixed items are translated");
// The catalogs hold the notes, and no file or folder name.
let strings = menus::menu_strings();
assert!(scripts_menu::LABELS.iter().all(|note| strings.contains(*note)));
assert!(strings.contains("Layers") && !strings.contains("All Artboards"));
write(&dir, "Zed/All Artboards.js", "");
read(&app);
assert!(!menus::menu_strings().contains("All Artboards") && !menus::menu_strings().contains("Zed"));
assert!(!crate::palette::items().iter().any(|(_, id, _)| id == scripts_menu::COMMAND), "a script runs from its menu item");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_script_that_left_the_folder_is_not_run_and_the_folder_is_read_again() {
let dir = folder("gone");
let mut app = app(&dir);
let gone = write(&dir, "gone.js", DRAW);
write(&dir, "kept.js", "");
read(&app);
std::fs::remove_file(&gone).unwrap();
let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": gone })).unwrap().unwrap_err();
assert_eq!(e, "the script is no longer in the Scripts folder");
assert!(!app.session.script_running());
scripts_menu::wait();
assert_eq!(shape(&scripts()), "kept, -, Check Script…, Other Script…");
// A file outside the folder is not one of its scripts, and a call needs a path.
let outside = dir.join("outside.js");
std::fs::write(&outside, DRAW).unwrap();
let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": outside.to_string_lossy() })).unwrap().unwrap_err();
assert_eq!(e, "the script is no longer in the Scripts folder");
assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({})).unwrap().is_err());
assert!(menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": 3 })).unwrap().is_err());
finish(&mut app);
assert_eq!(objects(&app), 0);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_menu_notes_a_listing_that_stopped_and_a_folder_it_cannot_read() {
let dir = folder("notes");
let app = app(&dir);
for n in 0..vectorcraft_engine::script::folder::MAX_SCRIPTS + 1 {
write(&dir, &format!("s{n:04}.js"), "");
}
read(&app);
let items = scripts();
assert_eq!(items.len(), vectorcraft_engine::script::folder::MAX_SCRIPTS + 4);
assert_eq!(shape(&items[items.len() - 4..]), format!("({}), -, Check Script…, Other Script…", scripts_menu::TRUNCATED));
// A file where the folder should be.
std::fs::remove_dir_all(dir.join("Scripts")).unwrap();
std::fs::write(dir.join("Scripts"), "").unwrap();
read(&app);
assert_eq!(shape(&scripts()), format!("({}), -, Check Script…, Other Script…", scripts_menu::UNREADABLE));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_menu_listing_under_a_read_root_leaves_out_scripts_outside_it() {
use vectorcraft_engine::file_access::{AutomationRoots, confine};
let dir = folder("read-root");
let app = app(&dir);
let path = write(&dir, "Export/All Artboards.js", "");
read(&app);
let scripts = || menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some(scripts_menu::COMMAND)).collect::<Vec<_>>();
let listed = scripts();
assert_eq!(listed.len(), 1);
assert_eq!(
(&listed[0].path, &listed[0].label, &listed[0].params),
(&vec!["File".to_string(), "Scripts".into(), "Export".into()], &"All Artboards".to_string(), &json!({ "path": path }))
);
let work = dir.join("work");
std::fs::create_dir_all(&work).unwrap();
let outside = AutomationRoots::new(&[&work], &[]).unwrap().unwrap();
assert!(confine(Some(&outside), scripts).is_empty());
let inside = AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
assert_eq!(confine(Some(&inside), scripts).len(), 1);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn names_past_the_session_limit_are_left_out_with_the_note() {
let dir = folder("names-limit");
let app = app(&dir);
write(&dir, "Kept.js", "");
read(&app);
assert_eq!(shape(&scripts()), "Kept, -, Check Script…, Other Script…");
// Names other listings showed fill what one session keeps.
for n in 0..scripts_menu::MAX_NAMES {
if scripts_menu::intern(&format!("renamed {n}")).is_none() {
break;
}
}
write(&dir, "New.js", "");
write(&dir, "Folder/Inside.js", "");
read(&app);
assert_eq!(shape(&scripts()), format!("Kept, ({}), -, Check Script…, Other Script…", scripts_menu::TRUNCATED));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_macos_menu_bar_lists_the_same_scripts_and_follows_a_new_listing() {
let dir = folder("native");
let app = app(&dir);
let path = write(&dir, "Export/All Artboards.js", "");
let before = scripts_menu::revision();
read(&app);
assert_eq!(scripts_menu::revision(), before + 1);
let bar = native_menu::from_tree(&app, &menus::menu_tree());
let file = bar.menus.iter().find(|m| m.title == "File").unwrap();
let scripts = file.children.iter().find_map(|n| match n {
Node::Submenu { label, children } if label == "Scripts" => Some(children),
_ => None,
});
let Some([Node::Submenu { label, children }, Node::Separator, Node::Item(check), Node::Item(other)]) = scripts.map(Vec::as_slice) else {
panic!("{scripts:?}")
};
assert_eq!((label.as_str(), check.command, other.command), ("Export", Some("ui.checkScript"), Some("ui.runScript")));
let [Node::Item(script)] = children.as_slice() else { panic!("{children:?}") };
assert_eq!((script.label.as_str(), script.command, &script.params), ("All Artboards", Some(scripts_menu::COMMAND), &json!({ "path": path })));
assert!(script.enabled && script.shortcut.is_none());
// A read that finds the same scripts changes nothing the menu bar would rebuild for.
read(&app);
assert_eq!(scripts_menu::revision(), before + 1);
write(&dir, "More.jsx", "");
read(&app);
assert_eq!(scripts_menu::revision(), before + 2);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_folder_is_read_at_the_first_frame_and_when_the_window_becomes_active() {
let dir = folder("focus");
let app = app(&dir);
write(&dir, "first.js", "");
let ctx = egui::Context::default();
let frame = |focused: bool| {
let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| scripts_menu::poll(&app, ui.ctx()));
out.textures_delta.clear();
scripts_menu::wait();
};
let listed = || shape(&scripts());
frame(true);
assert!(listed().starts_with("first, -"), "{}", listed());
// While the window stays active, or stays in the background, the folder is left alone.
write(&dir, "second.js", "");
frame(true);
frame(false);
frame(false);
assert!(listed().starts_with("first, -"), "{}", listed());
frame(true);
assert!(listed().starts_with("first, second, -"), "{}", listed());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_app_without_a_scripts_folder_keeps_the_two_fixed_items() {
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
read(&app);
assert_eq!(shape(&scripts()), "Check Script…, Other Script…");
let e = menus::run_ui_command(&mut app, scripts_menu::COMMAND, &json!({ "path": "draw.js" })).unwrap().unwrap_err();
assert_eq!(e, "the script is no longer in the Scripts folder");
assert_eq!(menus::UI_COMMANDS.iter().filter(|c| c.0 == scripts_menu::COMMAND).count(), 1);
let _: Value = app.run("script.listInstalled", json!({})).unwrap();
}