Files
vectorcraft/crates/engine/tests/plugin_call.rs
T
dotCooCoo 811c21e7c2 Plug-ins: calls need doc.read, and an edit in progress is kept before a calling run
A command's result describes the document, so a plug-in without doc.read
gets -6 for every call, whether or not the id names a command. A run that
calls commands commits an edit still in progress first and prepares its
input again when that changed the document or the selection.
2026-11-10 11:26:10 -08:00

195 lines
10 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.
//! A filter plug-in whose manifest's `commandAllowlist` names commands calls them with `vc_call`:
//! each call sees what the calls before it left, the calls and the filter's output are one undo
//! step, and a run that fails leaves the document as it was.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat as tpl;
/// The command imports and the strings the plug-ins pass: the ids "select.all" (0x2000, 10 bytes),
/// "object.move" (0x2010, 11), "no.such.cmd" (0x2020, 11), "effect.apply" (0x2030, 12) and
/// "select.globalEditOptions" (0x2040, 24), and the arguments `{"dx":5,"dy":0}` (0x2100, 15),
/// `{"dx":"x"}` (0x2200, 10), `{"effect":"plugin.test.call.fx"}` (0x2300, 32) and `{"size":true}`
/// (0x2400, 13).
const CALLS: &str = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32)))
(import "env" "vc_call_result" (func $vc_call_result (param i32 i32) (result i32)))
(data (i32.const 0x2000) "select.all")
(data (i32.const 0x2010) "object.move")
(data (i32.const 0x2020) "no.such.cmd")
(data (i32.const 0x2030) "effect.apply")
(data (i32.const 0x2040) "select.globalEditOptions")
(data (i32.const 0x2100) "{\22dx\22:5,\22dy\22:0}")
(data (i32.const 0x2200) "{\22dx\22:\22x\22}")
(data (i32.const 0x2300) "{\22effect\22:\22plugin.test.call.fx\22}")
(data (i32.const 0x2400) "{\22size\22:true}")"#;
/// Selects everything.
const SELECT_ALL: &str = "(drop (call $vc_call (i32.const 0x2000) (i32.const 10) (i32.const 0) (i32.const 0)))";
/// Moves the selection 5 to the right.
const MOVE: &str = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2100) (i32.const 15)))";
const EMPTY: &str = r#"{"objects":[]}"#;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// Installs filter `id` with `permissions` (a JSON list) and `allowlist` (a JSON list), whose
/// `vc_run` runs `first`, then returns `output` → what `plugin.install` returned, or why it refused.
fn install(s: &mut Session, id: &str, permissions: &str, allowlist: &str, first: &str, output: &str) -> Result<Value, String> {
let manifest =
format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{permissions},"commandAllowlist":{allowlist}}}"#);
let wasm = wat::parse_str(tpl::with_imports(&manifest, CALLS, first, output)).unwrap();
let grant: Value = serde_json::from_str(permissions).unwrap();
let data = vectorcraft_format::base64_encode(&wasm);
s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant, "replace": true})).map_err(|e| e.to_string())
}
/// A filter `id` that may read and change the document and call `select.all` and `object.move`.
fn install_mover(s: &mut Session, id: &str, first: &str, output: &str) {
install(s, id, r#"["doc.read","doc.write","commands"]"#, r#"["select.all","object.move"]"#, first, output).unwrap();
}
/// A document with two rectangles and nothing selected.
fn document() -> Session {
let mut s = Session::new();
s.allow_plugin_grants_from_requests();
run(&mut s, "file.new", json!({"width": 400, "height": 200}));
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}));
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50}));
run(&mut s, "select.none", json!({}));
s
}
/// The active document's layers, its undo and redo steps and how many objects are selected.
fn state(s: &mut Session) -> (Value, usize, usize, usize) {
let content = run(s, "document.inspect", json!({}))["layers"].take();
let d = s.doc().unwrap();
(content, d.history.undo.len(), d.history.redo.len(), d.selection.len())
}
/// Runs filter `id` on nothing, so only its calls change the document.
fn run_plugin(s: &mut Session, id: &str) -> Result<Value, String> {
s.execute("plugin.run", &json!({"id": id, "ids": []})).map_err(|e| e.to_string())
}
#[test]
fn calls_see_each_others_changes_and_make_one_undo_step() {
let mut s = document();
let before = state(&mut s);
install_mover(&mut s, "test.call.mover", &[SELECT_ALL, MOVE].concat(), EMPTY);
run_plugin(&mut s, "test.call.mover").unwrap();
let after = state(&mut s);
// `object.move` moved what `select.all` selected: both rectangles.
let xs: Vec<&Value> = after.0[0]["children"].as_array().unwrap().iter().map(|c| &c["bounds"]["x"]).collect();
assert_eq!(xs, [&json!(105.0), &json!(15.0)]);
assert_eq!(after.1, before.1 + 1, "one undo step");
assert_eq!(s.doc().unwrap().history.undo.last().map(|u| u.label.as_str()), Some("Test test.call.mover"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(state(&mut s).0, before.0);
run(&mut s, "edit.redo", json!({}));
assert_eq!(state(&mut s).0, after.0);
// Without `select.all` nothing is selected, so `object.move` has nothing to move.
run(&mut s, "select.none", json!({}));
install_mover(&mut s, "test.call.mover", MOVE, EMPTY);
assert!(run_plugin(&mut s, "test.call.mover").is_err());
run(&mut s, "plugin.remove", json!({"id": "test.call.mover"}));
}
#[test]
fn a_run_that_fails_after_its_calls_changes_nothing() {
let mut s = document();
let before = state(&mut s);
// A trap, an output that isn't JSON, and a command that fails, each after the move.
let bad_move = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2200) (i32.const 10)))";
for (first, output) in
[([SELECT_ALL, MOVE, "unreachable"].concat(), EMPTY), ([SELECT_ALL, MOVE].concat(), "nope"), ([SELECT_ALL, MOVE, bad_move].concat(), EMPTY)]
{
install_mover(&mut s, "test.call.fails", &first, output);
assert!(run_plugin(&mut s, "test.call.fails").is_err(), "{first} {output}");
assert_eq!(state(&mut s), before, "{first} {output}");
}
run(&mut s, "plugin.remove", json!({"id": "test.call.fails"}));
// Session state a call changed outside the document goes back too: Global Edit's options.
let options = "(drop (call $vc_call (i32.const 0x2040) (i32.const 24) (i32.const 0x2400) (i32.const 13))) unreachable";
let allowlist = r#"["select.globalEditOptions"]"#;
install(&mut s, "test.call.options", r#"["doc.read","commands"]"#, allowlist, options, EMPTY).unwrap();
assert!(run_plugin(&mut s, "test.call.options").is_err());
assert_eq!(run(&mut s, "select.globalEditOptions", json!({}))["size"], json!(false));
run(&mut s, "plugin.remove", json!({"id": "test.call.options"}));
}
/// A command that changes state a failed run can't put back (the preferences, a tool's options, a
/// drag's state) is refused at install, by name.
#[test]
fn install_refuses_reviewed_denials() {
let mut s = document();
for refused in ["object.liquify", "object.pathfinder.options", "addon.pen.nextSegment"] {
let allowlist = format!(r#"["select.all","{refused}"]"#);
let e = install(&mut s, "test.call.reviewed", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err();
assert!(e.contains(refused) && e.contains("may not call"), "{e}");
}
}
#[test]
fn install_refuses_a_command_a_plugin_may_not_call_and_reports_unknown_ones() {
let mut s = document();
let first = "(drop (call $vc_call (i32.const 0x2020) (i32.const 11) (i32.const 0) (i32.const 0)))";
for refused in ["document.save", "edit.undo", "plugin.run", "script.run"] {
let allowlist = format!(r#"["select.all","{refused}"]"#);
let e = install(&mut s, "test.call.refused", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err();
assert!(e.contains(refused) && e.contains("may not call"), "{e}");
}
let r = install(&mut s, "test.call.unknown", r#"["doc.read","commands"]"#, r#"["select.all","no.such.cmd"]"#, first, EMPTY).unwrap();
assert_eq!(r["unknownCommands"], json!(["no.such.cmd"]));
let e = run_plugin(&mut s, "test.call.unknown").unwrap_err();
assert!(e.contains("no.such.cmd"), "{e}");
run(&mut s, "plugin.remove", json!({"id": "test.call.unknown"}));
}
#[test]
fn a_call_that_names_a_plugin_is_refused() {
let mut s = document();
let before = state(&mut s);
let first = format!("{SELECT_ALL} (drop (call $vc_call (i32.const 0x2030) (i32.const 12) (i32.const 0x2300) (i32.const 32)))");
install(&mut s, "test.call.fx", r#"["doc.read","doc.write","commands"]"#, r#"["select.all","effect.apply"]"#, &first, EMPTY).unwrap();
let e = run_plugin(&mut s, "test.call.fx").unwrap_err();
assert!(e.contains("may not call"), "{e}");
assert_eq!(state(&mut s), before);
run(&mut s, "plugin.remove", json!({"id": "test.call.fx"}));
}
#[test]
fn without_doc_write_a_call_that_edits_is_refused() {
let mut s = document();
let before = state(&mut s);
let allowlist = r#"["select.all","object.move"]"#;
install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, &[SELECT_ALL, MOVE].concat(), EMPTY).unwrap();
let e = run_plugin(&mut s, "test.call.reader").unwrap_err();
assert!(e.contains("may not call"), "{e}");
assert_eq!(state(&mut s), before);
// Selecting changes no art, so it runs.
install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, SELECT_ALL, EMPTY).unwrap();
run_plugin(&mut s, "test.call.reader").unwrap();
run(&mut s, "plugin.remove", json!({"id": "test.call.reader"}));
}
/// A command's result describes the document, so a plug-in without `doc.read` may call nothing.
#[test]
fn without_doc_read_every_call_is_refused() {
let mut s = document();
let before = state(&mut s);
install(&mut s, "test.call.blind", r#"["commands"]"#, r#"["select.all"]"#, SELECT_ALL, EMPTY).unwrap();
let e = run_plugin(&mut s, "test.call.blind").unwrap_err();
assert!(e.contains("may not call") && e.contains("select.all"), "{e}");
assert_eq!(state(&mut s), before);
// The same for an id no command has: without `doc.read` the answer never depends on the command.
let unknown = "(drop (call $vc_call (i32.const 0x2020) (i32.const 11) (i32.const 0) (i32.const 0)))";
install(&mut s, "test.call.blind", r#"["commands"]"#, r#"["no.such.cmd"]"#, unknown, EMPTY).unwrap();
let e = run_plugin(&mut s, "test.call.blind").unwrap_err();
assert!(e.contains("may not call") && e.contains("no.such.cmd"), "{e}");
run(&mut s, "plugin.remove", json!({"id": "test.call.blind"}));
}