Files
vectorcraft/crates/engine/src/tests/plugins.rs
T
BFlat 5402f17f15 Merge pull request #6276 from dotCooCoo/fix/plugin-last-error-origin
Plug-ins: lastError keeps a failed run's error, and hit tests follow swatch changes
2026-11-09 12:26:56 +08:00

2713 lines
160 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.
//! WebAssembly plug-ins (#121): object filters through `plugin.run` (one undo step,
//! transactional), live-effect plug-ins in appearance stacks, and the `plugin.*` management
//! commands.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_plugins::wat as tpl;
use crate::*;
/// The example Desaturate plug-in (built from `crates/plugins/example`).
const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm");
const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate";
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// Installs `wasm`, in place of any module installed with its id (`replace: true`).
fn install(s: &mut Session, wasm: &[u8]) -> Value {
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true}))
}
fn install_wat(s: &mut Session, src: &str) -> Value {
install(s, &wat::parse_str(src).unwrap())
}
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
s
}
/// A 10 × 10 rectangle at `x` filled with `fill` and no stroke.
fn rect(s: &mut Session, x: f64, fill: &str) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
id
}
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn triangle() -> PathData {
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 0.0), Point::new(15.0, 20.0)], true))
}
#[test]
fn object_filter_recolours_the_selection_as_one_undo_step() {
let mut s = session();
let info = install(&mut s, DESATURATE);
assert_eq!((info["id"].as_str(), info["kind"].as_str()), (Some(DESATURATE_ID), Some("filter")));
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 40.0, "#0000ff");
run(&mut s, "select.all", json!({}));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "params": {"amount": 100}}));
assert_eq!(r["ids"], json!([a.0, b.0]));
for id in [a, b] {
let [r, g, b] = node(&s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated();
assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6, "grey: {r} {g} {b}");
}
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.history.undo.last().unwrap().label, "Desaturate");
assert_eq!(st.selection.objects, [a, b]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
// Plug-in parameters may also be top-level keys; wrong types are refused before it runs.
run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "amount": 0}));
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. })));
}
/// `plugin.run` refuses an `ids` list with an entry that is not an object id, with an error naming
/// the key, the entry and its value, and changes nothing. With `ids` left out or null the filter
/// runs on the selection, as it does on the list of the selection.
#[test]
fn object_filter_ids_entries_must_be_ids_and_ids_left_out_take_the_selection() {
// A session with the plug-in, a red and a blue rectangle, and the red one selected. The
// document has no creation date, so documents made in different seconds compare equal.
let fresh = || {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 200, "created": null}));
install(&mut s, DESATURATE);
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 40.0, "#0000ff");
run(&mut s, "select.set", json!({"ids": [a.0]}));
(s, a, b)
};
let (_, a, b) = fresh();
for (bad, entry) in [(json!(["abc"]), r#"entry 0 is "abc""#), (json!([a.0, true]), "entry 1 is true"), (json!([{}]), "entry 0 is {}")] {
let (mut s, ..) = fresh();
let st = s.doc().unwrap();
let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len());
let r = s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": 100, "ids": bad}));
let want = format!("`ids` {entry}, not a non-negative integer object id");
assert!(matches!(&r, Err(EngineError::BadParams { cmd, msg }) if cmd == "plugin.run" && *msg == want), "{bad}: {r:?}");
let st = s.doc().unwrap();
assert!((st.doc.clone(), st.selection.clone(), st.history.undo.len()) == before, "{bad}: something changed");
}
let gray = |s: &Session, id: NodeId| {
let [r, g, b] = node(s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated();
(r - g).abs() < 1e-6 && (g - b).abs() < 1e-6
};
let mut docs = vec![];
for ids in [None, Some(Value::Null), Some(json!([a.0]))] {
let (mut s, ..) = fresh();
let mut p = json!({"id": DESATURATE_ID, "amount": 100});
if let Some(ids) = &ids {
p["ids"] = ids.clone();
}
let r = run(&mut s, "plugin.run", p);
assert_eq!(r["ids"], json!([a.0]), "{ids:?}");
assert!(gray(&s, a) && !gray(&s, b), "{ids:?}: only the selected rectangle turns gray");
docs.push(s.doc().unwrap().doc.clone());
}
assert!(docs.windows(2).all(|w| w[0] == w[1]), "left out, null and the selection's list give the same document");
}
#[test]
fn object_filters_add_replace_and_generate_objects() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 40.0, "#00ff00");
run(&mut s, "select.all", json!({}));
// Keeps A, drops B and adds a triangle above A.
let out =
json!({"objects": [{"id": a.0}, {"path": triangle(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 1}}], "name": "Tri"}]});
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.replace", "filter", "{}"), &out.to_string()));
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.replace"}));
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids[0], a);
let st = s.doc().unwrap();
assert!(st.doc.node(b).is_none());
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(layer, [a, ids[1]]);
assert_eq!(st.doc.node(ids[1]).unwrap().name.as_deref(), Some("Tri"));
assert_eq!(st.selection.objects, ids);
// A generator with nothing selected adds to the current layer.
let generated = json!({"objects": [{"path": triangle()}]});
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.generate", "filter", "{}"), &generated.to_string()));
run(&mut s, "select.none", json!({}));
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.generate"}));
let made = NodeId(r["ids"][0].as_u64().unwrap());
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made);
}
/// A generator can make guides and lock what it adds, in its one undo step; those objects are left
/// out of the selection that follows, as Make Guides and Object › Lock leave them.
#[test]
fn filters_make_guides_and_locked_objects_left_unselected() {
let mut s = session();
let out = json!({"objects": [
{"path": triangle(), "name": "Guide", "guide": true},
{"path": triangle(), "name": "Locked", "locked": true},
{"path": triangle(), "name": "Plain"}]});
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.guides", "filter", "{}"), &out.to_string()));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.guides"}));
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert!(matches!(node(&s, ids[0]).kind, vectorcraft_doc::NodeKind::Path { guide: true, .. }));
assert!(node(&s, ids[1]).locked);
let st = s.doc().unwrap();
assert_eq!(st.selection.objects, [ids[2]]);
assert_eq!(st.history.undo.len(), undo + 1);
}
#[test]
fn failing_plugins_leave_the_document_and_history_alone() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let man = |id: &str| tpl::manifest(id, "filter", "{}");
install_wat(&mut s, &tpl::module(&man("test.engine.trap"), "unreachable", "", 1));
install_wat(&mut s, &tpl::returning(&man("test.engine.far"), r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#));
install_wat(&mut s, &tpl::returning(&man("test.engine.declines"), r#"{"error":"Select two paths."}"#));
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for id in ["test.engine.trap", "test.engine.far", "test.engine.declines", "test.engine.missing"] {
let e = s.execute("plugin.run", &json!({"id": id})).unwrap_err();
if id == "test.engine.declines" {
assert!(e.to_string().contains("Select two paths."), "{e}");
}
assert_eq!(s.doc().unwrap().doc, before, "{id}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{id}");
}
}
#[test]
fn live_effect_plugins_reshape_editable_art_and_expand() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
// An effect that draws a triangle, and one that hands the geometry back.
let tri = json!({"objects": [{"path": triangle()}]});
install_wat(
&mut s,
&tpl::returning(&tpl::manifest("test.engine.tri", "effect", r#"{"size":{"type":"number","min":0,"max":10,"default":3}}"#), &tri.to_string()),
);
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.echo", "effect", "{}"), tpl::ECHO, "", 1));
let catalog = run(&mut s, "effect.list", json!({}))["catalog"].clone();
let entry = catalog.as_array().unwrap().iter().find(|e| e["id"] == "plugin.test.engine.tri").expect("listed");
assert_eq!((entry["label"].as_str(), entry["menu"].clone()), (Some("Test test.engine.tri…"), json!(["Effect", "Plug-ins"])));
assert_eq!(entry["defaults"], json!({"size": 3.0}));
// A filter can't be applied as an effect, nor an effect run as a filter.
install(&mut s, DESATURATE);
assert!(s.execute("effect.apply", &json!({"ids": [a.0], "effect": format!("plugin.{DESATURATE_ID}")})).is_err());
assert!(s.execute("plugin.run", &json!({"id": "test.engine.tri"})).is_err());
let effected = |s: &Session| {
let n = node(s, a);
let path = n.path_data().unwrap().clone();
let b = path.bounds().unwrap();
vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &path, b)
};
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.echo"}));
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap(), "identity");
// Re-evaluated when the object changes (non-destructive).
run(&mut s, "select.all", json!({}));
run(&mut s, "object.move", json!({"dx": 50, "dy": 0}));
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}}));
assert_eq!(effected(&s), triangle());
assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4}));
// Uninstalled: the geometry is drawn as it is.
run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"}));
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.tri", "effect", "{}"), &tri.to_string()));
// Expand Appearance bakes it.
run(&mut s, "select.all", json!({}));
run(&mut s, "effect.expandAppearance", json!({}));
let mut baked = None;
s.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
baked = Some(p.clone());
}
});
assert_eq!(baked.unwrap().bounds(), triangle().bounds());
}
/// A live effect is given its object's fills and strokes, read only, and which of them it sits on;
/// a change of paint runs it again.
#[test]
fn live_effects_are_given_their_object_s_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"}));
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 5}));
// The red fill as the host writes paints: a JSON value, its keys sorted.
let red = serde_json::to_value(&node(&s, id).appearance.fill().unwrap().paint).unwrap().to_string();
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
// Each draws the triangle while it is given what it looks for, else hands the geometry back.
let effect = |id: &str, given, needle: &str| tpl::when_given(&tpl::manifest(id, "effect", "{}"), given, needle, &tri);
install_wat(&mut s, &effect("test.engine.wide", tpl::Given::Input, r#""width":5.0"#));
install_wat(&mut s, &effect("test.engine.red", tpl::Given::Input, &format!(r#""fills":[{red}]"#)));
install_wat(&mut s, &effect("test.engine.second", tpl::Given::Params, r#""item":{"stroke":1}"#));
let effected = |s: &Session, item: Option<usize>| {
let n = node(s, id);
let path = n.path_data().unwrap().clone();
let ctx = vectorcraft_render::effects::GeomContext::of(&n);
let (effects, ctx) = match item {
Some(i) => match &n.appearance.items[i] {
vectorcraft_doc::AppearanceItem::Stroke(st) => (st.effects.clone(), ctx.item(&n.appearance.items[i])),
other => panic!("not a stroke: {other:?}"),
},
None => (n.appearance.effects.clone(), ctx),
};
vectorcraft_render::effects::apply_geometry_with(&effects, &path, path.bounds().unwrap(), &ctx)
};
let own = node(&s, id).path_data().unwrap().clone();
let remove = |s: &mut Session| run(s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": null}));
for effect in ["plugin.test.engine.wide", "plugin.test.engine.red"] {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": null}));
assert_eq!(effected(&s, None), triangle(), "{effect}: given the 5 pt stroke and the red fill");
remove(&mut s);
}
// Run again once the stroke is thinner.
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.wide", "item": null}));
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 3, "item": 1}));
assert_eq!(effected(&s, None), own, "a 3 pt stroke");
remove(&mut s);
// On the second of two strokes, and not on the first.
run(&mut s, "appearance.addStroke", json!({"ids": [id.0]}));
let top = node(&s, id).appearance.items.len() - 1;
for item in [1, top] {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.second", "item": item}));
}
assert_eq!(effected(&s, Some(1)), own, "the first stroke");
assert_eq!(effected(&s, Some(top)), triangle(), "the second stroke");
}
/// Expand gives a plug-in effect on a fill the context drawing gives it (`_context.item`), so a
/// gradient fill is expanded into the shape the canvas fills.
#[test]
fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
let on_fill = tpl::when_given(&tpl::manifest("test.engine.onfill", "effect", "{}"), tpl::Given::Params, r#""item":{"fill":0}"#, &tri);
install_wat(&mut s, &on_fill);
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onfill", "item": fill}));
// Drawn, the gradient fills the triangle: a point of it outside the rectangle is painted.
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let drawn = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts);
assert_ne!(drawn.pixel(15, 1), [255, 255, 255, 255], "drawn as a triangle");
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"steps": 2}));
// The gradient's strips sit in a clip group whose clipping path is the shape filled.
let group = s.doc().unwrap().selection.objects[0];
let clip = node(&s, group).children().unwrap()[0].clone();
assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills");
}
/// The manifest of a test live effect `id` that declares `paintOutput` (at `api` 2), or that
/// doesn't (`paints` off).
fn painting(id: &str, paints: bool) -> String {
let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" };
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#)
}
/// A 20 × 20 square at `x`, 40.
fn square_at(x: f64) -> PathData {
PathData::single(SubPath::polyline(&[Point::new(x, 40.0), Point::new(x + 20.0, 40.0), Point::new(x + 20.0, 60.0), Point::new(x, 60.0)], true))
}
fn solid(hex: &str) -> Value {
serde_json::to_value(Paint::solid(Color::from_hex(hex).unwrap())).unwrap()
}
/// The 200 × 200 top left of the document, drawn on white.
fn drawn(s: &Session) -> vectorcraft_render::Rendered {
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
}
const WHITE: [u8; 4] = [255, 255, 255, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
const GREEN: [u8; 4] = [0, 255, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// A live effect that declares `paintOutput` draws each piece it returns by itself, with the paints
/// it gives (a piece without `fills` keeps the object's), in place of the object; the same output
/// from an effect that doesn't declare it is one path painted by the object's fill. Drawing and
/// export leave the document alone, and Expand Appearance writes the pieces as ordinary paths in
/// one undo step.
#[test]
fn a_painted_live_effect_draws_its_pieces_with_their_own_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.paints", true), &out));
install_wat(&mut s, &tpl::returning(&painting("test.engine.geometry", false), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geometry"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (RED, RED, WHITE), "without paintOutput the paint is ignored");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.paints"}));
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, RED, WHITE), "each piece with its own paint");
// An upstream paint edit reaches the piece that keeps the object's fill; Undo brings it back.
run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": "#0000ff"}));
assert_eq!((drawn(&s).pixel(50, 50), drawn(&s).pixel(90, 50)), (GREEN, BLUE));
run(&mut s, "edit.undo", json!({}));
assert_eq!(drawn(&s).pixel(90, 50), RED);
run(&mut s, "edit.redo", json!({}));
assert_eq!(drawn(&s).pixel(90, 50), BLUE);
run(&mut s, "edit.undo", json!({}));
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(svg.contains("#00ff00") && svg.contains("#ff0000"), "{svg}");
assert_eq!(s.doc().unwrap().doc, before, "drawing and export don't change the document");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Expanded: two ordinary paths, green and red, and no live effect, as one undo step.
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "effect.expandAppearance", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let mut paths = vec![];
s.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
assert!(n.appearance.effects.is_empty(), "no live effect left");
paths.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone())));
}
});
let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap()));
assert_eq!(paths, [(square_at(40.0).bounds(), fill("#00ff00")), (square_at(80.0).bounds(), fill("#ff0000"))]);
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).appearance.effects[0].id, "plugin.test.engine.paints");
}
/// On a fill, a painted effect returns only fills: they replace that fill on each piece. A result
/// that breaks a rule (strokes from an effect on a fill, a swatch the document doesn't have) is
/// refused whole: the object is drawn as if the effect weren't there, and `plugin.info` names the
/// plug-in and the field.
#[test]
fn a_painted_effect_on_a_fill_and_refused_results() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let fills = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string();
let strokes = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#00ff00"), "width": 2}]}]}).to_string();
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Not a swatch");
let unknown = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.fillpaint", true), &fills));
install_wat(&mut s, &tpl::returning(&painting("test.engine.strokepaint", true), &strokes));
install_wat(&mut s, &tpl::returning(&painting("test.engine.swatchpaint", true), &unknown));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.fillpaint", "item": fill}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the fill's piece, green");
assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.fillpaint"}))["lastError"], Value::Null);
// A second painted effect on the same fill paints what it returns too.
let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.bluepaint", true), &blue));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.bluepaint", "item": fill}));
let img = drawn(&s);
assert_eq!((img.pixel(90, 50), img.pixel(50, 50), img.pixel(15, 15)), (BLUE, WHITE, WHITE), "the second effect's piece, blue");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 1, "item": fill}));
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": fill}));
for (effect, item, field) in
[("test.engine.strokepaint", json!(fill), "objects[0].strokes"), ("test.engine.swatchpaint", Value::Null, "objects[0].fills[0].swatch")]
{
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{effect}"), "item": item}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (WHITE, RED), "{effect}: drawn as it is");
let e = run(&mut s, "plugin.info", json!({"id": effect}))["lastError"].as_str().unwrap_or_default().to_string();
assert!(e.starts_with(&format!("Test {effect}: ")) && e.contains(field), "{effect}: {e}");
let n = node(&s, id);
let kept = if item.is_null() { n.appearance.effects.clone() } else { n.appearance.items[fill].effects().to_vec() };
assert_eq!(kept.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [format!("plugin.{effect}")], "the effect record stays");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": item}));
}
}
/// A result refused for a swatch the document doesn't have stays cached: once the document has the
/// swatch, the next check of that result clears the error.
#[test]
fn a_refused_swatch_s_error_clears_once_the_document_has_the_swatch() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let mut later = solid("#00ff00");
later["swatch"] = json!("Later");
let output = json!({"objects": [{"path": square_at(40.0), "fills": [later]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterswatch", true), &output));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.laterswatch"}));
drawn(&s);
assert!(run(&mut s, "plugin.info", json!({"id": "test.engine.laterswatch"}))["lastError"].is_string());
run(&mut s, "swatch.new", json!({"name": "Later", "color": "#00ff00", "global": true}));
drawn(&s);
assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.laterswatch"}))["lastError"], Value::Null);
}
/// An effect that paints nothing has no references to check, so replacing a refused painted effect
/// with one clears the refusal's error once it runs.
#[test]
fn a_replacement_that_paints_nothing_clears_a_refused_swatch_s_error() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Absent");
let output = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.replaced", true), &output));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.replaced"}));
drawn(&s);
assert!(run(&mut s, "plugin.info", json!({"id": "test.engine.replaced"}))["lastError"].is_string());
install_wat(&mut s, &tpl::returning(&painting("test.engine.replaced", false), &output));
drawn(&s);
assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.replaced"}))["lastError"], Value::Null);
}
/// A painted effect on a group may link its pieces to the document's swatches.
#[test]
fn a_painted_group_effect_links_to_the_document_s_swatches() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true}));
run(&mut s, "select.all", json!({}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.grouppaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.grouppaint"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green");
}
/// The output of a painted effect that returns one green square linked to swatch `Leaf`.
fn leaf_square() -> String {
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string()
}
/// A Pathfinder operation and baked appearance take a painted effect's pieces, swatch links
/// included, as the canvas draws them: a group with a painted effect inside a group with a live
/// Pathfinder effect draws the green piece, and baking a path's appearance gives the piece linked
/// to the swatch.
#[test]
fn pathfinder_and_baked_appearance_take_painted_pieces_with_swatch_links() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.leafpaint", true), &leaf_square()));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let inner = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [inner.0], "effect": "plugin.test.engine.leafpaint"}));
let outer = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [outer.0], "effect": "pathfinder.add"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the Pathfinder operation takes the green piece");
let id = rect(&mut s, 120.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.leafpaint"}));
let st = s.doc().unwrap();
let baked = vectorcraft_render::effects::bake_appearance(Some(&st.doc), st.doc.node(id).unwrap()).unwrap();
let mut fills = vec![];
baked.walk(&mut |n| {
if let Some(p) = n.path_data() {
fills.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone())));
}
});
let mut leaf = Paint::solid(Color::from_hex("#00ff00").unwrap());
if let Paint::Solid { swatch, .. } = &mut leaf {
*swatch = Some("Leaf".into());
}
assert_eq!(fills, [(square_at(40.0).bounds(), Some(leaf))]);
}
/// A swatch-linked piece on screen is drawn when the path it comes from is off screen: the
/// renderer's bounds of the path take in the piece.
#[test]
fn a_swatch_linked_piece_is_not_culled_with_its_off_screen_path() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.culled", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.culled"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// The renderer measures a painted piece with its own strokes: a piece off screen whose wide stroke
/// reaches the view is drawn, and the bounds raster effects work over take in that stroke, on a
/// path with no stroke of its own and with a blur on the stroke the effect is on.
#[test]
fn a_piece_s_own_stroke_counts_in_the_bounds_of_what_is_drawn() {
let mut s = session();
let wide = json!({"objects": [{"path": square_at(-120.0), "strokes": [{"paint": solid("#00ff00"), "width": 300}]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.widestroke", true), &wide));
let id = rect(&mut s, -300.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.widestroke"}));
assert_eq!(drawn(&s).pixel(30, 50), GREEN, "only the piece's stroke is on screen");
let bounds = |s: &Session, id: NodeId| vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, &node(s, id)).unwrap();
assert!(bounds(&s, id).x1 >= 50.0, "{:?}", bounds(&s, id));
let blurred = rect(&mut s, -300.0, "#ff0000");
run(&mut s, "paint.setStroke", json!({"ids": [blurred.0], "color": "#0000ff", "width": 1}));
let stroke = node(&s, blurred).appearance.items.iter().position(|i| !i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "plugin.test.engine.widestroke", "item": stroke}));
run(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "blur.gaussian", "params": {"radius": 10}, "item": stroke}));
assert!(bounds(&s, blurred).x1 > 55.0, "the blur spreads the piece's stroke: {:?}", bounds(&s, blurred));
}
/// A retained renderer's kept bounds follow the document's swatches: a swatch-linked piece on
/// screen appears when its swatch is added, though the path it comes from is off screen.
#[test]
fn a_retained_renderer_shows_a_piece_once_its_swatch_exists() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.lateleaf", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.lateleaf"}));
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = vectorcraft_render::Renderer::new();
let at_piece = |r: &mut vectorcraft_render::Renderer, s: &Session| {
r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50)
};
assert_eq!(at_piece(&mut r, &s), WHITE, "the piece is refused");
run(&mut s, "select.none", json!({}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(at_piece(&mut r, &s), GREEN, "the piece is drawn with its swatch");
}
/// Object › Expand of a gradient fill works piece by piece, for a painted effect on the object and
/// on the fill: a piece that keeps the gradient has it expanded, and a piece with a paint of its own,
/// here linked to the document's swatch, keeps that paint on its own path.
#[test]
fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() {
for on_fill in [false, true] {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}, {"path": square_at(100.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandref", true), &out));
let item = if on_fill { json!(node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap()) } else { Value::Null };
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandref", "item": item}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false}));
let img = drawn(&s);
assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, GREEN), "on_fill {on_fill}: the input is gone and the piece keeps its paint");
assert!(![WHITE, GREEN].contains(&img.pixel(110, 50)), "on_fill {on_fill}: the other piece shows the gradient");
let mut own = Paint::solid(Color::from_hex("#00ff00").unwrap());
if let Paint::Solid { swatch, .. } = &mut own {
*swatch = Some("Leaf".into());
}
let mut kept = vec![];
let mut plugins = 0;
s.doc().unwrap().doc.walk(|n| {
plugins += n
.appearance
.effects
.iter()
.chain(n.appearance.items.iter().flat_map(|i| i.effects()))
.filter(|e| e.id.starts_with("plugin."))
.count();
if n.path_data().is_some_and(|p| p.bounds() == square_at(40.0).bounds()) {
kept.push(n.appearance.fill().map(|f| f.paint.clone()));
}
});
assert_eq!(kept, [Some(own)], "on_fill {on_fill}");
assert_eq!(plugins, 0, "on_fill {on_fill}: no live effect is left");
}
}
/// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its
/// tiles expanded, and a piece with a paint of its own keeps that paint.
#[test]
fn expanding_a_pattern_fill_keeps_each_piece_s_own_paint() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let tile = rect(&mut s, 300.0, "#00ff00");
run(&mut s, "object.pattern.make", json!({"ids": [tile.0], "name": "Tiles", "edit": false}));
run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Tiles"}));
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}, {"path": square_at(100.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandtiles", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandtiles"}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false}));
let img = drawn(&s);
assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, BLUE), "the input is gone and the piece keeps its paint");
assert!(near(img.pixel(110, 50), GREEN), "the other piece shows the tiles");
let mut patterns = 0;
s.doc().unwrap().doc.walk(|n| patterns += n.appearance.items.iter().filter(|i| matches!(i.paint(), Paint::Pattern { .. })).count());
assert_eq!(patterns, 0, "the tiles are objects");
}
/// Two painted effects on one fill, the first returning a piece with two fills: the second replaces
/// the first of them and the other fill stays over it.
#[test]
fn a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string();
let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &two));
install_wat(&mut s, &tpl::returning(&painting("test.engine.onefill", true), &one));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twofills", "item": fill}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onefill", "item": fill}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN, "the other fill paints over the replaced one");
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut paints = vec![];
art.walk(&mut |n| {
if n.path_data().is_some() {
paints.push(n.appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect::<Vec<_>>());
}
});
let fill = |hex: &str| Paint::solid(Color::from_hex(hex).unwrap());
assert!(paints.contains(&vec![fill("#0000ff"), fill("#00ff00")]), "{paints:?}");
}
/// A Transform effect before a painted effect on a fill moves the fill's placed gradient: a piece
/// that keeps the fill, and the fill drawn on the moved shape after a refused result, both have the
/// gradient at its moved position.
#[test]
fn a_painted_effect_on_a_fill_keeps_a_placed_gradient_with_its_moved_shape() {
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Not a swatch");
for (name, out, want) in [
("keeps", json!({"objects": [{"path": square_at(40.0)}]}).to_string(), square_at(40.0).bounds()),
("refused", json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string(), square_at(110.0).bounds()),
] {
let mut s = session();
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 40, "width": 20, "height": 20}))["id"].as_u64().unwrap());
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear", "stops": stops}}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "paint.setGradientGeom", json!({"start": [14, 50], "end": [26, 50], "stroke": false}));
let plugin = format!("test.engine.carry{name}");
install_wat(&mut s, &tpl::returning(&painting(&plugin, true), &out));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "distort.transform", "params": {"moveH": 100}, "item": fill}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{plugin}"), "item": fill}));
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut seen = vec![];
art.walk(&mut |n| {
if let (Some(p), Some(Paint::Gradient(g))) = (n.path_data(), n.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint())) {
seen.push((p.bounds(), g.geom.map(|g| (g.start.x, g.end.x))));
}
});
assert_eq!(seen, [(want, Some((114.0, 126.0)))], "{name}");
}
}
/// The geometry of a painted result that names a swatch counts only when the document has that
/// swatch: without a document, and with one that lacks it, the effect keeps its input.
#[test]
fn painted_geometry_is_used_only_when_its_references_are_in_the_document() {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with};
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.geomref", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geomref"}));
let n = node(&s, id);
let path = n.path_data().unwrap().clone();
let input = path.bounds().unwrap();
let geometry = |doc: Option<&vectorcraft_doc::Document>| {
apply_geometry_with(&n.appearance.effects, &path, input, &GeomContext { doc, ..GeomContext::of(&n) }).bounds()
};
assert_eq!(geometry(None), Some(input), "no document");
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), Some(input), "no such swatch");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), square_at(40.0).bounds(), "the piece's geometry");
}
/// Type with a painted effect, in a group with a live Pathfinder effect: the operation takes the
/// piece linked to the document's swatch.
#[test]
fn pathfinder_takes_the_swatch_linked_pieces_of_type() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.typepaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [t.0], "effect": "plugin.test.engine.typepaint"}));
run(&mut s, "select.set", json!({"ids": [t.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// Type whose fill has a painted effect, in a group with a geometry effect inside a live Pathfinder
/// effect: the operation takes the piece linked to the document's swatch.
#[test]
fn pathfinder_takes_swatch_linked_pieces_of_type_in_a_reshaped_group() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.memberpaint", true), &leaf_square()));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.memberpaint", "item": 0}));
let inner = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [inner.0], "effect": "path.offsetPath", "params": {"offset": 0}}));
let outer = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [outer.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// A painted effect on an image's own fill that links its piece to a swatch: hit tests and bounds
/// use the piece the canvas draws.
#[test]
fn an_image_s_painted_fill_is_hit_and_measured_as_drawn() {
let mut s = session();
let mut png = vec![];
image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]}));
let im = NodeId(r["ids"][0].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(120.0), "fills": [leaf]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.imagepaint", true), &out));
run(&mut s, "select.set", json!({"ids": [im.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "convertToShape.rectangle", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.imagepaint", "item": 0}));
assert_eq!(drawn(&s).pixel(130, 50), GREEN, "the canvas draws the piece");
let doc = &s.doc().unwrap().doc;
assert!(vectorcraft_doc::hit::hit_test(doc, Point::new(130.0, 50.0), Default::default()).is_some(), "a click on the piece hits the image");
let boxes = doc.symbol_boxes();
assert!(doc.node(im).unwrap().reach_bounds_with(&boxes).unwrap().x1 >= 140.0, "the bounds take in the piece");
}
/// A path with a painted effect takes part in a Pathfinder operation as its pieces, each with its
/// own paint and the path's opacity: Divide on a group holding it makes one face per piece,
/// painted as the piece is.
#[test]
fn a_path_with_a_painted_effect_takes_part_in_pathfinder_as_its_pieces() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0), "fills": [solid("#0000ff")]}]})
.to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaint"}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "pathfinder.divide"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, BLUE, WHITE), "one face per piece");
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50}));
let st = s.doc().unwrap();
let hook = |c: &vectorcraft_doc::Node| vectorcraft_render::effects::outline_object(Some(&st.doc), c);
let faces = vectorcraft_render::effects::pathfinder_children(Some(&st.doc), st.doc.node(g).unwrap(), Some(&hook)).unwrap();
let faces: Vec<_> = faces.iter().map(|f| (f.appearance.fill().map(|f| f.paint.clone()), f.opacity)).collect();
let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap()));
assert_eq!(faces, [(fill("#0000ff"), 0.5), (fill("#00ff00"), 0.5)]);
}
/// Art a renderer keeps for an object with a painted effect is made again when a swatch its pieces
/// name leaves the document (the object is then drawn as if the effect weren't there) and when
/// Undo brings the swatch back.
#[test]
fn kept_art_follows_the_swatches_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.keptpaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.keptpaint"}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| {
let img = r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
(img.pixel(50, 50), img.pixel(15, 15))
};
assert_eq!(draw(&s), (GREEN, WHITE), "the piece, green");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
assert_eq!(draw(&s), (WHITE, RED), "without the swatch, the object as it is");
run(&mut s, "edit.undo", json!({}));
assert_eq!(draw(&s), (GREEN, WHITE), "the swatch back, the piece again");
}
/// A piece linked to a global swatch shows the swatch's edited color, in art a renderer keeps and
/// in a kept raster of a blur over it, and its earlier color after Undo.
#[test]
fn a_piece_linked_to_a_global_swatch_takes_its_edited_color() {
for blur in [false, true] {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.globalleaf", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.globalleaf"}));
if blur {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
}
run(&mut s, "select.none", json!({}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut at_piece = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50);
assert!(near(at_piece(&s), GREEN), "the piece, in the swatch's color (blur {blur})");
run(&mut s, "swatch.edit", json!({"name": "Leaf", "color": "#0000ff"}));
let px = at_piece(&s);
assert!(near(px, BLUE), "the swatch's new color (blur {blur}): {px:?}");
run(&mut s, "edit.undo", json!({}));
assert!(near(at_piece(&s), GREEN), "the swatch's color after Undo (blur {blur})");
}
}
/// Art a renderer keeps for an object whose plug-in starts to paint its pieces when it is replaced
/// follows the swatches those pieces name from then on.
#[test]
fn kept_art_follows_the_swatches_once_a_replaced_plugin_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterpaint", false), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.laterpaint"}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| {
let img = r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
(img.pixel(50, 50), img.pixel(15, 15))
};
assert_eq!(draw(&s), (RED, WHITE), "the square in the object's paint");
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterpaint", true), &leaf_square()));
assert_eq!(draw(&s), (GREEN, WHITE), "the piece, green");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
assert_eq!(draw(&s), (WHITE, RED), "without the swatch, the object as it is");
}
/// Whether `px` is within 40 levels of `want` on every channel. A blur leaves the middle of a
/// shape close to its color.
fn near(px: [u8; 4], want: [u8; 4]) -> bool {
px.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 40)
}
/// A kept raster of a blur over a painted effect is made again when a swatch its piece names
/// leaves the document (the object is then drawn as if the effect weren't there) and when Undo
/// brings the swatch back.
#[test]
fn a_kept_raster_follows_the_swatches_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.blurpaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.blurpaint"}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
let img = draw(&s);
assert!(near(img.pixel(50, 50), GREEN) && near(img.pixel(15, 15), WHITE), "the piece, green and blurred");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
let img = draw(&s);
assert!(near(img.pixel(50, 50), WHITE) && near(img.pixel(15, 15), RED), "without the swatch, the object as it is: {:?}", img.pixel(50, 50));
run(&mut s, "edit.undo", json!({}));
let img = draw(&s);
assert!(near(img.pixel(50, 50), GREEN) && near(img.pixel(15, 15), WHITE), "the swatch back, the piece again");
}
/// A kept raster of a blur over a painted effect is made again when the tile of a pattern its
/// piece names changes and the pattern keeps its name.
#[test]
fn a_kept_raster_follows_the_tiles_of_patterns_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let green = rect(&mut s, 300.0, "#00ff00");
let blue = rect(&mut s, 320.0, "#0000ff");
let tile = |r: f64, art: vectorcraft_doc::Node| vectorcraft_doc::pattern::PatternDef {
tile: vectorcraft_geom::Rect::new(r, 10.0, r + 10.0, 20.0),
..vectorcraft_doc::pattern::PatternDef::new("Tiles", vec![std::sync::Arc::new(art)])
};
let (first, second) = (tile(300.0, node(&s, green)), tile(320.0, node(&s, blue)));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.push(first);
let paint = serde_json::to_value(Paint::Pattern { pattern: "Tiles".into(), xf: vectorcraft_geom::Affine::IDENTITY }).unwrap();
let out = json!({"objects": [{"path": square_at(40.0), "fills": [paint]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.tilepaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.tilepaint"}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50);
assert!(near(draw(&s), GREEN), "the piece in the green tile");
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns[0] = second;
let px = draw(&s);
assert!(near(px, BLUE), "the piece in the new tile: {px:?}");
}
/// Every painted piece counts against the effects' budget, however small its path: two painted
/// effects in a stack that each return 350 tiny triangles make no more than 100,000 objects, not
/// 350 × 350 of them.
#[test]
fn painted_pieces_keep_to_the_effects_budget() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true));
let out = json!({"objects": vec![json!({"path": tiny}); 350]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.many", true), &out));
for _ in 0..2 {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.many"}));
}
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut paths = 0;
art.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
assert!((350..=100_000).contains(&paths), "{paths} paths");
}
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
#[test]
fn stroke_width_test_effect() {
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_PAINTS_WASM") else { return };
let triangle = PathData::single(SubPath::polyline(&[Point::new(100.0, 230.0), Point::new(300.0, 230.0), Point::new(200.0, 80.0)], true));
let manifest = r#"{"id":"org.example.strokewidth","name":"Stroke Width (test plug-in)","version":"1","kind":"effect",
"description":"Draws a triangle while the object's stroke is 5 pt wide.","params":{}}"#;
let src = tpl::when_given(manifest, tpl::Given::Input, r#""width":5.0"#, &json!({"objects": [{"path": triangle}]}).to_string());
std::fs::write(std::path::Path::new(&dir).join("stroke-width-test-effect.wasm"), wat::parse_str(src).unwrap()).unwrap();
}
/// A filter is given its objects' effect records and their strokes' join, cap and miter limit, and
/// may return them. The records are admitted as effect.apply would admit them; one that isn't
/// refuses the whole output, and nothing changes.
#[test]
fn filters_set_effects_and_stroke_styles() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(
&mut s,
&tpl::module(&tpl::manifest("test.engine.fx", "effect", r#"{"n":{"type":"int","min":0,"max":3,"default":1}}"#), tpl::ECHO, "", 1),
);
let black = json!({"type": "solid", "color": {"model": "gray", "k": 1.0}});
let out = |fx: Value| json!({"objects": [{"id": id.0, "strokes": [{"paint": black, "width": 4, "join": "round", "cap": "square", "miterLimit": 4}], "effects": fx}]});
let filter = |s: &mut Session, name: &str, fx: Value| {
install_wat(s, &tpl::returning(&tpl::manifest(name, "filter", "{}"), &out(fx).to_string()));
s.execute("plugin.run", &json!({"id": name, "ids": [id.0]}))
};
let fx = json!([{"id": "distort.roughen", "params": {"size": 3}}, {"id": "plugin.test.engine.fx", "params": {"n": 2}, "visible": false}]);
filter(&mut s, "test.engine.setfx", fx).unwrap();
let n = node(&s, id);
let st = n.appearance.stroke().unwrap();
assert_eq!((st.width, st.join, st.cap, st.miter_limit), (4.0, vectorcraft_doc::LineJoin::Round, vectorcraft_doc::LineCap::Square, 4.0));
let effects = &n.appearance.effects;
assert_eq!(effects.iter().map(|e| (e.id.as_str(), e.visible)).collect::<Vec<_>>(), [("distort.roughen", true), ("plugin.test.engine.fx", false)]);
assert_eq!(effects[0].params, vectorcraft_render::effects::merged_params("distort.roughen", &json!({"size": 3})), "filled in");
assert_eq!(effects[1].params, json!({"n": 2}));
// Handed back unchanged (an echo), nothing changes, not even the records' parameters.
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.same", "filter", "{}"), tpl::ECHO, "", 1));
let before = s.doc().unwrap().doc.clone();
run(&mut s, "plugin.run", json!({"id": "test.engine.same", "ids": [id.0]}));
assert_eq!(s.doc().unwrap().doc, before);
// Refused: what effect.apply refuses, a live effect that isn't installed, parameters its
// manifest refuses, a texture to read from a file, two 3D effects.
for (name, fx, why) in [
("test.engine.unknown", json!([{"id": "distort.nothing"}]), "no such effect"),
("test.engine.key", json!([{"id": "distort.roughen", "params": {"bogus": 1}}]), "bogus"),
("test.engine.gone", json!([{"id": "plugin.test.engine.gone"}]), "no such effect"),
("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"),
("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"),
] {
let e = filter(&mut s, name, fx).unwrap_err().to_string();
assert!(e.contains("objects[0].effects") && e.contains(why), "{name}: {e}");
assert_eq!(s.doc().unwrap().doc, before, "{name}");
}
}
/// `CRAFT_PLUGIN_FRAME_WASM=<folder>` saves a test filter to try in the app
/// (`rough-frame-test-filter.wasm`): it adds a frame around 60,40–340,260 with a 6 pt stroke with
/// round joins and caps, roughened by the Roughen effect.
#[test]
fn rough_frame_test_filter() {
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_FRAME_WASM") else { return };
let frame = PathData::single(SubPath::polyline(
&[Point::new(60.0, 40.0), Point::new(340.0, 40.0), Point::new(340.0, 260.0), Point::new(60.0, 260.0)],
true,
));
let ink = json!({"type": "solid", "color": {"model": "rgb", "r": 0.15, "g": 0.2, "b": 0.45}});
let out = json!({"objects": [{"name": "Frame", "path": frame, "fills": [],
"strokes": [{"paint": ink, "width": 6, "join": "round", "cap": "round"}],
"effects": [{"id": "distort.roughen", "params": {"size": 2, "detail": 8, "points": "smooth"}}]}]});
let manifest = r#"{"id":"org.example.frame","name":"Rough Frame (test plug-in)","version":"1","kind":"filter",
"description":"Adds a frame whose stroke is roughened by the Roughen effect.","params":{}}"#;
let src = tpl::returning(manifest, &out.to_string());
std::fs::write(std::path::Path::new(&dir).join("rough-frame-test-filter.wasm"), wat::parse_str(src).unwrap()).unwrap();
}
/// A filter keeps data with the document: it writes it with `documentData` in the same undo step as
/// its objects (`null` removes it) and reads what it last wrote in `_context.documentData`, which
/// no other filter is given. It is the document's foreign data, so the native file keeps it with
/// no section of its own. More than the limit is refused.
#[test]
fn filters_keep_their_data_with_the_document() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let pid = "test.engine.data";
let returning = |out: Value| tpl::returning(&tpl::manifest(pid, "filter", "{}"), &out.to_string());
let keep = |data: Value| json!({"objects": [{"id": id.0}], "documentData": data});
let data = |s: &Session| s.doc().unwrap().doc.plugin_data(pid).cloned();
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
install_wat(&mut s, &returning(keep(json!({"pen": 2}))));
filter(&mut s, pid).unwrap();
assert_eq!(data(&s), Some(json!({"pen": 2})));
run(&mut s, "edit.undo", json!({}));
assert_eq!(data(&s), None, "one undo step with the objects");
run(&mut s, "edit.redo", json!({}));
// Read back: these name the object only when they are given what they look for.
let named = |name: &str| json!({"objects": [{"id": id.0, "name": name}]}).to_string();
let when_given =
|pid: &str, needle: &str, name: &str| tpl::when_given(&tpl::manifest(pid, "filter", "{}"), tpl::Given::Params, needle, &named(name));
install_wat(&mut s, &when_given(pid, r#""documentData":{"pen":2}"#, "Pen 2"));
filter(&mut s, pid).unwrap();
assert_eq!(node(&s, id).name.as_deref(), Some("Pen 2"));
// Another filter is given nothing: what a filter keeps is its own.
let other = "test.engine.other";
install_wat(&mut s, &when_given(other, r#""documentData":null"#, "Nothing kept"));
filter(&mut s, other).unwrap();
assert_eq!(node(&s, id).name.as_deref(), Some("Nothing kept"));
// Saved with the document, among its foreign data.
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(saved.plugin_data(pid), Some(&json!({"pen": 2})));
assert_eq!(saved.unknown.get(vectorcraft_doc::plugin_data::PLUGIN_DATA), Some(&json!({pid: {"pen": 2}})));
// Too much is refused, and the document stays as it was; null removes the data.
let before = s.doc().unwrap().doc.clone();
let big = "x".repeat(vectorcraft_plugins::objects::MAX_DOCUMENT_DATA);
install_wat(&mut s, &returning(keep(json!(big))));
let e = filter(&mut s, pid).unwrap_err().to_string();
assert!(e.contains("documentData"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
install_wat(&mut s, &returning(keep(Value::Null)));
filter(&mut s, pid).unwrap();
assert_eq!(data(&s), None);
}
#[test]
fn plugin_management_commands() {
let mut s = session();
install(&mut s, DESATURATE);
let list = run(&mut s, "plugin.list", json!({}));
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == DESATURATE_ID));
let info = run(&mut s, "plugin.info", json!({"id": DESATURATE_ID}));
assert_eq!(info["defaults"], json!({"amount": 100.0}));
assert_eq!(info["params"], "{amount: 0..100 (100)}");
assert_eq!(info["manifest"]["kind"], "filter");
// Refused installs.
for bad in [
json!({}),
json!({"dataBase64": "!!"}),
json!({"dataBase64": "AGFzbQ=="}),
json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE), "replace": false}),
] {
assert!(s.execute("plugin.install", &bad).is_err(), "{bad}");
}
assert!(s.execute("plugin.info", &json!({"id": "test.engine.nothing"})).is_err());
assert!(s.execute("plugin.remove", &json!({"id": "test.engine.nothing"})).is_err());
assert!(s.execute("plugin.reload", &json!({})).is_err(), "no folder set");
// The Additional Plug-ins Folder preference installs what's in it.
let dir = std::env::temp_dir().join(format!("vectorcraft-engine-plugins-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = tpl::returning(&tpl::manifest("test.engine.folder", "filter", "{}"), r#"{"objects":[]}"#);
std::fs::write(dir.join("folder.wasm"), wat::parse_str(src).unwrap()).unwrap();
std::fs::write(dir.join("broken.wasm"), b"\0asm?").unwrap();
run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": dir.display().to_string()}));
assert!(vectorcraft_plugins::registry::get("test.engine.folder").is_some());
run(&mut s, "plugin.remove", json!({"id": "test.engine.folder"}));
let r = run(&mut s, "plugin.reload", json!({}));
assert_eq!(r["loaded"], json!(["test.engine.folder"]));
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"]));
std::fs::remove_dir_all(&dir).ok();
}
/// #2541: a plug-in installed in a session with a plug-in store is installed again in the next
/// session that attaches the store, and Remove Plug-in takes it out of the store.
#[test]
fn installed_plugins_are_kept_in_the_store() {
let folder = crate::tests_links::Folder::new("plugin-store");
let id = "test.engine.stored";
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_wat(&mut s, &src);
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "one file in the store");
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store");
run(&mut next, "plugin.remove", json!({"id": id}));
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 0, "removed from the store");
// A file that isn't a plug-in is skipped; the store still attaches.
std::fs::write(folder.0.join("broken.wasm"), b"\0asm?").unwrap();
let mut third = session();
third.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none());
}
/// A heavy test filter `id` that counts `steps` down, then adds a triangle named "Plug-in".
fn heavy(id: &str, steps: u64) -> Vec<u8> {
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
wat::parse_str(tpl::spinning(&manifest, steps, &output.to_string())).unwrap()
}
/// A filter that asks for a heavy budget installs only once the user agrees (`allowHeavy`);
/// `plugin.inspect` tells whoever installs it so beforehand, and installs nothing.
#[test]
fn a_heavy_filter_installs_once_the_user_agrees() {
let mut s = session();
let id = "test.engine.heavy";
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
// With the file's name, as the app passes it.
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data, "name": "route.wasm"}));
assert_eq!((inspected["id"].as_str(), inspected["budget"].as_str()), (Some(id), Some("heavy")));
assert_eq!(inspected["source"], "route.wasm");
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("heavy budget") && e.contains("5 minutes") && e.contains("allowHeavy"), "{e}");
assert!(vectorcraft_plugins::registry::get(id).is_none());
let info = run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
assert_eq!(info["budget"], "heavy");
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
let listed = run(&mut s, "plugin.list", json!({}));
assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["id"] == id && p["budget"] == "heavy"));
// A light plug-in's entry says nothing of budgets.
assert!(install(&mut s, DESATURATE).get("budget").is_none());
}
#[test]
fn invalid_install_flags_are_refused() {
let mut s = session();
let id = "test.engine.heavy.flags";
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
for allow in [json!("true"), json!(1)] {
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": allow})).unwrap_err().to_string();
assert!(e.contains("`allowHeavy` must be true or false"), "allowHeavy {allow}: {e}");
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with allowHeavy {allow}");
}
// Null, as for every command, is the same as leaving the flag out.
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": null})).unwrap_err().to_string();
assert!(e.contains("heavy budget"), "{e}");
run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
for replace in [json!("false"), json!(0)] {
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": true, "replace": replace})).unwrap_err().to_string();
assert!(e.contains("`replace` must be true or false"), "replace {replace}: {e}");
}
run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": null}));
assert!(vectorcraft_plugins::registry::get(id).is_some());
// A plug-in with the usual budget is refused too.
let light = "test.engine.light.flags";
let manifest = format!(r#"{{"id":"{light}","name":"Test {light}","version":"1","kind":"filter"}}"#);
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::spinning(&manifest, 1, &output.to_string())).unwrap());
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": "false"})).unwrap_err().to_string();
assert!(e.contains("`allowHeavy` must be true or false"), "{e}");
assert!(vectorcraft_plugins::registry::get(light).is_none());
}
/// A test filter `id` (named "Test `id`") whose manifest lists `permissions` (a JSON list) and that
/// returns `output`.
fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"2.1","kind":"filter","permissions":{permissions}}}"#);
wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap()
}
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
/// hash. The grant is recorded with that hash and covers only that module: replacing the plug-in
/// (`replace: true`) with one with other bytes needs a new grant. Removing it forgets the grant. A
/// plug-in without `permissions` installs as before, with the ones its kind implies.
#[test]
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
let mut s = session();
let id = "test.engine.permissions.install";
let empty = json!({"objects": []});
let data = |perms: &str| vectorcraft_format::base64_encode(&asking(id, perms, &empty));
let reads = data(r#"["doc.read"]"#);
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": reads}));
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read"])));
let hash = vectorcraft_plugins::permissions::sha256_hex(&asking(id, r#"["doc.read"]"#, &empty));
assert_eq!(inspected["hash"], hash);
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
// Refused without a grant that holds every permission it lists, naming them.
for (grant, says) in [
(Value::Null, "asks for the permissions doc.read"),
(json!([]), "asks for the permissions doc.read"),
(json!(["doc.write"]), "asks for the permissions doc.read"),
(json!(["doc.read", "net.fetch"]), "unknown permission `net.fetch`"),
(json!("doc.read"), "`grant` must be a list"),
(json!([1]), "`grant` must be a list"),
] {
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": grant})).unwrap_err().to_string();
assert!(e.contains(says), "grant {grant}: {e}");
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with grant {grant}");
}
// `hash` pins the module the user was shown.
for wrong in [json!("0".repeat(64)), json!(1)] {
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": ["doc.read"], "hash": wrong})).unwrap_err().to_string();
assert!(e.contains("SHA-256 `hash`"), "hash {wrong}: {e}");
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with hash {wrong}");
}
let installed = run(&mut s, "plugin.install", json!({"dataBase64": reads, "grant": ["doc.read"], "hash": hash}));
assert_eq!(installed["permissions"], json!(["doc.read"]));
let g = vectorcraft_plugins::registry::granted(id).unwrap();
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
// Replaced with another module that lists the same permissions: granted again, the grant now
// records the new module's hash.
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], true);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"], "replace": true}));
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
// One that asks for more is refused until it is granted all of them.
let more = data(r#"["doc.read","doc.write"]"#);
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true);
let e = s.execute("plugin.install", &json!({"dataBase64": more, "replace": true})).unwrap_err().to_string();
assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}");
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays");
run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"], "replace": true}));
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded");
// Removed, it is asked again.
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(vectorcraft_plugins::registry::granted(id).is_none());
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsApproval"], true);
// A plug-in without `permissions` installs as before, with its kind's.
let plain = install(&mut s, DESATURATE);
assert_eq!(plain["permissions"], json!(["doc.read", "doc.write", "state"]));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
}
/// A module with other bytes under an id that was granted is not covered, even when it lists the
/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed
/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes
/// install again without asking, and a module that lists no permissions never asks.
#[test]
fn a_grant_covers_only_the_module_that_was_approved() {
use vectorcraft_plugins::permissions::sha256_hex;
let mut s = session();
let id = "test.engine.permissions.bytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed));
let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed));
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true);
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true})).unwrap_err().to_string();
assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}");
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true, "hash": changed_hash})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "`hash` is not an approval: {e}");
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays");
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays");
// The same bytes need no new grant.
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false);
run(&mut s, "plugin.install", json!({"dataBase64": first_data}));
// A new grant installs the changed module, and the old bytes are no longer covered.
run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash, "replace": true}));
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash);
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash);
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true);
run(&mut s, "plugin.remove", json!({"id": id}));
// A manifest without a list, or with an empty one, asks for nothing, whatever its bytes.
let exempt = "test.engine.permissions.exempt";
for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] {
let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}");
run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true}));
}
run(&mut s, "plugin.remove", json!({"id": exempt}));
}
/// A stored module whose bytes changed is skipped at the next start and stays in the store with its
/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing.
/// With a store attached, a matching grant held only by the process doesn't count.
#[test]
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
let folder = crate::tests_links::Folder::new("plugin-store-recorded-module");
let id = "test.engine.permissions.storedbytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
let module = std::fs::read_dir(&folder.0)
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|x| x == "wasm"))
.expect("the stored module");
let forget = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
};
let starts = || {
forget();
let mut next = session();
next.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::get(id).is_some()
};
assert!(starts(), "the module that was approved loads");
std::fs::write(&module, &changed).unwrap();
assert!(!starts(), "other bytes under the old grant are skipped");
assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store");
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays");
std::fs::write(&module, &first).unwrap();
assert!(starts(), "the approved bytes load again");
// A malformed hash is no wildcard, even for the module on disk.
let upper = sha256_hex(&first).to_uppercase();
for hash in ["", "00", upper.as_str()] {
std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap();
assert!(!starts(), "hash {hash:?}");
}
// The store's grants are the ones that count, even when the process holds one for these bytes.
std::fs::write(&grants, &recorded).unwrap();
assert!(starts());
let mut attached = session();
attached.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
run(&mut attached, "plugin.remove", json!({"id": id}));
}
/// A write to the plug-in store that fails leaves the store's module beside the grant that approved
/// it, so the old module loads at the next start: a module that can't be written doesn't update the
/// grant, and a grant that can't be written puts the old module back. On a first install, it takes
/// the new module out again.
#[test]
fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
use vectorcraft_plugins::permissions::sha256_hex;
let folder = crate::tests_links::Folder::new("plugin-store-failed-write");
let id = "test.engine.permissions.failedwrite";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
let first_data = vectorcraft_format::base64_encode(&first);
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap());
let starts = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string())
};
let block = |path: &std::path::Path| {
std::fs::remove_file(path).unwrap();
std::fs::create_dir_all(path.join("block")).unwrap();
};
// The module can't be written: the grant stays the old module's.
block(&module);
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored");
std::fs::remove_dir_all(&module).unwrap();
std::fs::write(&module, &first).unwrap();
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write");
// The grants file is full: the new grant, which is longer, doesn't fit.
let cap = 1 << 20;
let mut entries: Vec<Value> = serde_json::from_slice(&recorded).unwrap();
entries.push(json!({"id": "", "pad": ""}));
let room = cap - serde_json::to_vec(&entries).unwrap().len();
*entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)});
let full = serde_json::to_vec(&entries).unwrap();
assert_eq!(full.len(), cap);
std::fs::write(&grants, &full).unwrap();
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write");
// The grants file can't be read, so nothing can be recorded in it.
block(&grants);
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable");
// A stored module that can't be read back isn't replaced, so a failed grant write can't lose it.
let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1;
std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap();
block(&grants);
run(&mut s, "plugin.install", update);
assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
std::fs::write(&module, &first).unwrap();
// A first install whose grant can't be written leaves no module without a grant in the store.
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(!module.exists() && !grants.exists());
std::fs::create_dir_all(grants.join("block")).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
assert!(!module.exists(), "the store holds no module its grants don't cover");
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
}
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
/// granted on its own, the changed module loads from the folder. A session without a store uses the
/// grants held by the process.
#[test]
fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() {
let mut s = session();
let id = "test.engine.permissions.folderbytes";
let plain = "test.engine.permissions.folderplain";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let dir = crate::tests_links::Folder::new("plugin-folder-changed-module");
let file = dir.0.join("asks.wasm");
std::fs::write(&file, &first).unwrap();
std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
let path = json!({"path": dir.0.display().to_string()});
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}");
std::fs::write(&file, &changed).unwrap();
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!(r["loaded"], json!([plain]), "the other file still loads");
let failed = r["failed"].as_array().unwrap();
assert_eq!(failed.len(), 1, "{r}");
assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}");
let why = failed[0]["error"].as_str().unwrap();
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
let hash = vectorcraft_plugins::permissions::sha256_hex(&first);
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays");
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"], "replace": true}));
let r = run(&mut s, "plugin.reload", path);
assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}");
run(&mut s, "plugin.remove", json!({"id": id}));
run(&mut s, "plugin.remove", json!({"id": plain}));
}
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
/// `state` it is given no data kept with the document and may not write any.
#[test]
fn a_filter_does_only_what_its_permissions_allow() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
let grant = |s: &mut Session, wasm: &[u8], perms: Value| {
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms, "replace": true}));
};
// Denied `doc.write`: renaming the object is refused, and the document and history stay.
let reader = "test.engine.permissions.reader";
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0, "name": "Changed"}]})), json!(["doc.read"]));
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = filter(&mut s, reader).unwrap_err().to_string();
assert!(e.contains("wasn't granted `doc.write`"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Deleting it (leaving it out of the output) and adding objects are changes too.
for out in [json!({"objects": []}), json!({"objects": [{"id": id.0}, {"path": triangle()}]})] {
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &out), json!(["doc.read"]));
assert!(filter(&mut s, reader).is_err(), "{out}");
assert_eq!(s.doc().unwrap().doc, before, "{out}");
}
// Returning the object unchanged changes nothing, so it may.
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0}]})), json!(["doc.read"]));
filter(&mut s, reader).unwrap();
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
// Granted `doc.write` alone: it is given no objects, so its triangle is added beside the
// rectangle rather than replacing it.
let writer = "test.engine.permissions.writer";
let manifest = format!(r#"{{"id":"{writer}","name":"W","kind":"filter","permissions":["doc.write"]}}"#);
let added = json!({"objects": [{"path": triangle(), "name": "Given nothing"}]});
let src = tpl::when_given(&manifest, tpl::Given::Input, r#"{"objects":[]}"#, &added.to_string());
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.write"]));
let made = filter(&mut s, writer).unwrap();
let new = NodeId(made["ids"][0].as_u64().unwrap());
assert_eq!(node(&s, new).name.as_deref(), Some("Given nothing"));
assert!(s.doc().unwrap().doc.node(id).is_some(), "the rectangle it wasn't given stays");
// Without `state`: kept data is not handed over, and writing some is refused.
run(
&mut s,
"plugin.install",
json!({"dataBase64": vectorcraft_format::base64_encode(&asking("test.engine.permissions.keeper", "null", &json!({"objects": [{"id": id.0}], "documentData": {"pen": 1}})))}),
);
filter(&mut s, "test.engine.permissions.keeper").unwrap();
let stateless = "test.engine.permissions.keeper";
let manifest = format!(r#"{{"id":"{stateless}","name":"K","kind":"filter","permissions":["doc.read","doc.write"]}}"#);
let src =
tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":null"#, &json!({"objects": [{"id": id.0, "name": "No data"}]}).to_string());
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.read", "doc.write"]));
filter(&mut s, stateless).unwrap();
assert_eq!(node(&s, id).name.as_deref(), Some("No data"), "given no kept data");
let before = s.doc().unwrap().doc.clone();
grant(
&mut s,
&asking(stateless, r#"["doc.read","doc.write"]"#, &json!({"objects": [{"id": id.0}], "documentData": {"pen": 2}})),
json!(["doc.read", "doc.write"]),
);
let e = filter(&mut s, stateless).unwrap_err().to_string();
assert!(e.contains("wasn't granted `state`"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1})));
}
/// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo
/// changes nothing, and an output that leaves the type out or changes it is refused with the
/// document and undo history as they were, handed in from a run made elsewhere too. A filter
/// without `inputs` is not given the type and so never deletes it. Installing one that lists
/// `inputs` needs the user's grant.
#[test]
fn read_only_kind_field_and_omission_refuse_without_undo() {
use cmd::plugin::FilterRun;
let mut s = session();
let r = rect(&mut s, 10.0, "#ff0000");
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap());
let reads =
|id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"]}}"#);
// Each case installs another module under the same id, in place of the last (`replace: true`).
let install_wat = |s: &mut Session, src: &str| {
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write"], "replace": true}))
};
let echo = "test.engine.inputs.echo";
let module = tpl::module(&reads(echo), tpl::ECHO, "", 1);
let data = vectorcraft_format::base64_encode(&wat::parse_str(&module).unwrap());
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("asks for the permissions doc.read, doc.write"), "{e}");
let listed = install_wat(&mut s, &module);
assert_eq!(listed["inputs"], json!(["type"]));
let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]});
let before = s.doc().unwrap().doc.clone();
assert_eq!(run(&mut s, "plugin.run", p(echo))["ids"], json!([r.0, t.0]));
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "an echo changes nothing");
for (output, says) in [
(json!({"objects": [{"id": r.0}]}), format!("objects (id {}): a read-only object was left out", t.0)),
(json!({"objects": [{"id": r.0}, {"id": t.0, "name": "Renamed"}]}), format!("objects[1] (id {}): read-only field 'name' changed", t.0)),
(json!({"objects": [{"id": r.0}, {"id": t.0}, {"type": "type", "path": triangle()}]}), "`type` must be \"path\" or \"compound\"".into()),
] {
let id = "test.engine.inputs.bad";
install_wat(&mut s, &tpl::returning(&reads(id), &output.to_string()));
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string();
assert!(e.contains(&says), "{says}: {e}");
assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "{output}");
// Handed in from a run made away from plugin.run, it is refused the same way.
let made = FilterRun::prepare(&s, &p(id)).unwrap();
let out = made.run().unwrap();
made.hand_in(&mut s, out);
let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string();
assert!(e.contains(&says), "handed in: {e}");
assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "handed in: {output}");
}
// Without `inputs` the type isn't given, so leaving it out keeps it.
let legacy = "test.engine.inputs.legacy";
install_wat(&mut s, &tpl::returning(&tpl::manifest(legacy, "filter", "{}"), &json!({"objects": [{"id": r.0, "name": "Kept"}]}).to_string()));
assert_eq!(run(&mut s, "plugin.run", p(legacy))["ids"], json!([r.0]));
assert_eq!(node(&s, r).name.as_deref(), Some("Kept"));
assert!(matches!(node(&s, t).kind, vectorcraft_doc::NodeKind::Text(_)), "the type stays");
// A manifest that lists `inputs` below `api` 2 doesn't install.
let old = format!(r#"{{"id":"{echo}","name":"Old","kind":"filter","inputs":["type"]}}"#);
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::module(&old, tpl::ECHO, "", 1)).unwrap());
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("`inputs` needs `api` 2"), "{e}");
}
/// What the user granted is kept in the plug-in store with the module: the next session installs
/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is
/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder
/// until it is granted on its own.
#[test]
fn granted_permissions_are_kept_and_folders_skip_the_rest() {
let folder = crate::tests_links::Folder::new("plugin-store-grants");
let id = "test.engine.permissions.stored";
let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
assert!(folder.0.join("grants.json").is_file());
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store, as granted");
// Only a store's own grants count: the same module in another store without a grant is
// skipped, and a grant given elsewhere is not written to that store.
let other = crate::tests_links::Folder::new("plugin-store-grants-other");
std::fs::write(other.0.join("copy.wasm"), &wasm).unwrap();
vectorcraft_plugins::registry::remove(id);
let mut elsewhere = session();
elsewhere.attach_plugin_store(other.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none(), "granted in another store only");
let e = elsewhere.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "a grant kept in another store doesn't count here: {e}");
let lone = "test.engine.permissions.lone";
let lone_wasm = asking(lone, r#"["doc.read"]"#, &json!({"objects": []}));
run(&mut elsewhere, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&lone_wasm), "grant": ["doc.read"]}));
let kept = std::fs::read_to_string(other.0.join("grants.json")).unwrap();
assert!(kept.contains(lone) && !kept.contains(id), "{kept}");
run(&mut elsewhere, "plugin.remove", json!({"id": lone}));
assert!(!other.0.join("grants.json").exists(), "the last grant removed takes the file with it");
// Without the grant it is skipped, and the file stays.
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
std::fs::remove_file(folder.0.join("grants.json")).unwrap();
let mut third = session();
third.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it");
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store");
// The Additional Plug-ins Folder skips it with a note until it is granted.
let dir = crate::tests_links::Folder::new("plugin-folder-grants");
std::fs::write(dir.0.join("asks.wasm"), &wasm).unwrap();
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
assert_eq!(r["loaded"], json!([]));
let why = r["failed"][0]["error"].as_str().unwrap();
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
run(&mut third, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
assert_eq!(r["loaded"], json!([id]));
run(&mut third, "plugin.remove", json!({"id": id}));
// With a store attached, only that store's grants load it from the folder: a grant given in a
// session without a store doesn't count.
let mut storeless = session();
run(&mut storeless, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
vectorcraft_plugins::registry::remove(id);
assert!(vectorcraft_plugins::registry::granted(id).is_some(), "granted in the process");
let empty = crate::tests_links::Folder::new("plugin-store-grants-empty");
let mut scoped = session();
scoped.attach_plugin_store(empty.0.clone());
let r = run(&mut scoped, "plugin.reload", json!({"path": dir.0.display().to_string()}));
vectorcraft_plugins::registry::revoke(id);
assert_eq!(r["loaded"], json!([]), "a grant outside this store doesn't load it");
let why = r["failed"][0]["error"].as_str().unwrap();
assert!(why.contains("needs approval"), "{why}");
assert!(vectorcraft_plugins::registry::get(id).is_none(), "not installed");
// The preference's folder is loaded again for a session whose store holds other grants.
let pref_dir = crate::tests_links::Folder::new("plugin-folder-pref-grants");
std::fs::write(pref_dir.0.join("asks.wasm"), &wasm).unwrap();
let pref = json!({"key": "pluginsFolder", "value": pref_dir.0.display().to_string()});
run(&mut scoped, "prefs.set", pref.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nothing granted in this store");
let granting = crate::tests_links::Folder::new("plugin-store-grants-pref");
let mut granted = session();
granted.attach_plugin_store(granting.0.clone());
run(&mut granted, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
run(&mut granted, "prefs.set", pref);
assert!(vectorcraft_plugins::registry::get(id).is_some(), "loaded under this store's grant");
run(&mut granted, "plugin.remove", json!({"id": id}));
}
/// A grant covers the module it was given to: another module under the id, listing the same
/// permissions, isn't installed from the store at the next start, a folder load reports it as
/// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It
/// doesn't get the settings kept for the id until the user grants it. A kept grant without a hash
/// covers no module.
#[test]
fn a_grant_covers_only_the_module_it_was_given_to() {
let folder = crate::tests_links::Folder::new("plugin-store-grant-hash");
let id = "test.engine.granthash";
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_storing(&mut s, id, WRITES);
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
// The stored module is replaced on disk by one that reads the settings.
let other = storing(id, READS);
let stored: Vec<_> =
std::fs::read_dir(&folder.0).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "wasm")).collect();
assert_eq!(stored.len(), 1, "{stored:?}");
std::fs::write(&stored[0], &other).unwrap();
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
vectorcraft_plugins::registry::set_storage(id, None);
let no_storage = |how: &str| {
assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache");
};
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant");
no_storage("the next start");
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
std::fs::write(dir.0.join("other.wasm"), &other).unwrap();
let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()}));
assert_eq!(r["loaded"], json!([]));
let why = r["failed"][0]["error"].as_str().unwrap();
assert!(why.contains("needs approval"), "{why}");
no_storage("a folder load");
let data = vectorcraft_format::base64_encode(&other);
let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "{e}");
no_storage("a refused install");
// Granted, it reads the settings the first module set.
run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
// A kept grant that records no hash, or an empty one, covers no module: the stored module isn't
// installed at the next start and `plugin.inspect` asks for approval.
for entry in [json!({"id": id, "permissions": ["settings", "cache"]}), json!({"id": id, "hash": "", "permissions": ["settings", "cache"]})] {
std::fs::write(folder.0.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap();
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
let mut again = session();
again.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none(), "a grant without a hash doesn't load the module: {entry}");
assert_eq!(run(&mut again, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], json!(true), "{entry}");
next = again;
}
run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
run(&mut next, "plugin.remove", json!({"id": id}));
}
/// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is
/// skipped without dropping the other grants, and recording a grant writes it back as it was,
/// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a
/// list is never overwritten.
#[test]
fn grants_this_version_cant_read_are_kept() {
let folder = crate::tests_links::Folder::new("plugin-store-grants-unknown");
let kept = "test.engine.permissions.kept";
let added = "test.engine.permissions.added";
let later = "test.engine.permissions.later";
let empty = json!({"objects": []});
let kept_wasm = asking(kept, r#"["doc.read"]"#, &empty);
let added_wasm = asking(added, r#"["doc.read"]"#, &empty);
let later_wasm = asking(later, r#"["doc.read"]"#, &empty);
let future = json!({"id": later, "hash": "00", "permissions": ["doc.read", "ui.panel"]});
let invalid = json!({"id": "bad id/", "hash": "00", "permissions": ["doc.read"]});
let grant = json!({"id": kept, "hash": vectorcraft_plugins::permissions::sha256_hex(&kept_wasm), "permissions": ["doc.read"]});
let grants = folder.0.join("grants.json");
std::fs::write(&grants, serde_json::to_vec(&json!([future, grant, invalid])).unwrap()).unwrap();
std::fs::write(folder.0.join("kept.wasm"), &kept_wasm).unwrap();
let entries = || serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap();
let mut s = session();
s.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(kept).is_some(), "the entries this version can't read don't drop the grant next to them");
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
let now = entries();
assert_eq!(now.len(), 4, "{now:?}");
assert!(now.contains(&future) && now.contains(&invalid), "written back as they were: {now:?}");
assert!(now.iter().any(|e| e["id"] == added), "{now:?}");
run(&mut s, "plugin.remove", json!({"id": added}));
assert_eq!(entries().len(), 3);
// Granting the plug-in an entry names replaces that entry.
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&later_wasm), "grant": ["doc.read"]}));
let now = entries();
assert!(!now.contains(&future), "{now:?}");
assert_eq!(now.iter().filter(|e| e["id"] == later).count(), 1, "{now:?}");
run(&mut s, "plugin.remove", json!({"id": later}));
run(&mut s, "plugin.remove", json!({"id": kept}));
assert_eq!(entries(), vec![invalid.clone()], "the last usable grant removed leaves the entry it can't read");
// A file that doesn't hold a list is left as it is: installing still works in this session.
for broken in [&b"[{\"id\": "[..], &b"{\"id\": \"x\"}"[..]] {
std::fs::write(&grants, broken).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
assert!(vectorcraft_plugins::registry::get(added).is_some());
assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten");
run(&mut s, "plugin.remove", json!({"id": added}));
assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten by a removal either");
}
// The file stays within the 1 MiB it is read up to: compact when pretty-printing would pass it,
// and left as it is when even compact would.
let cap = 1 << 20;
let many: Vec<Value> = (0..14_000).map(|i| json!({"id": format!("x.{i}"), "hash": "00", "permissions": ["ui.panel"]})).collect();
let compact = serde_json::to_vec(&many).unwrap();
assert!(compact.len() < cap && serde_json::to_vec_pretty(&many).unwrap().len() > cap);
std::fs::write(&grants, &compact).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
let written = std::fs::read(&grants).unwrap();
assert!(written.len() <= cap, "{}", written.len());
assert_eq!(entries().len(), many.len() + 1);
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm)}));
assert_eq!(inspected["needsApproval"], json!(false), "the compact file is read back");
run(&mut s, "plugin.remove", json!({"id": added}));
let full = serde_json::to_vec(&json!([{"id": "x.full", "hash": "0".repeat(cap - 100), "permissions": ["ui.panel"]}])).unwrap();
std::fs::write(&grants, &full).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
run(&mut s, "plugin.remove", json!({"id": added}));
}
/// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next
/// start without asking, with its budget.
#[test]
fn a_stored_heavy_filter_keeps_its_budget() {
let folder = crate::tests_links::Folder::new("plugin-store-heavy");
let id = "test.engine.storedheavy";
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
let plugin = vectorcraft_plugins::registry::get(id).expect("installed again from the store");
assert_eq!(plugin.limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
run(&mut next, "plugin.remove", json!({"id": id}));
}
/// The app runs a heavy filter away from the session ([`cmd::plugin::FilterRun`]) and hands its
/// output in: `plugin.run` with the same params applies it, as one undo step, without running the
/// plug-in again. Only once, and only for the same run: objects that changed since, or the plug-in
/// installed again, run the plug-in instead.
#[test]
fn a_filter_run_made_elsewhere_is_applied_by_plugin_run() {
use cmd::plugin::FilterRun;
let mut s = session();
let id = "test.engine.handin";
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
let p = json!({"id": id, "ids": []});
let named = |s: &Session, ids: &Value| s.doc().unwrap().doc.node(NodeId(ids[0].as_u64().unwrap())).unwrap().name.clone();
// What it computed, as the app hands it in; here marked, so the test sees which output applied.
let r = FilterRun::prepare(&s, &p).unwrap();
assert!(r.current(&s));
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Plug-in", "Handed in");
r.hand_in(&mut s, output.clone().into_bytes());
let undo = s.doc().unwrap().history.undo.len();
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Handed in"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
// Once only: the next run runs the plug-in.
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"));
// Made for objects that changed since, it isn't theirs.
let obj = rect(&mut s, 10.0, "#ff0000");
let on = json!({"id": id, "ids": [obj.0]});
let stale = FilterRun::prepare(&s, &on).unwrap();
run(&mut s, "paint.setFill", json!({"ids": [obj.0], "color": "#00ff00"}));
assert!(!stale.current(&s), "the document changed");
stale.hand_in(&mut s, output.clone().into_bytes());
let made = run(&mut s, "plugin.run", on)["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made for objects that changed since");
// A plug-in installed again is another one.
let again = FilterRun::prepare(&s, &p).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
assert!(!again.current(&s));
again.hand_in(&mut s, output.into_bytes());
let made = run(&mut s, "plugin.run", p)["ids"].clone();
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made by the plug-in installed before");
}
/// A heavy filter run away from the session reads what it keeps with the document
/// (`_context.documentData`) and hands its `documentData` back with its output: `plugin.run`
/// applies both as one undo step.
#[test]
fn a_filter_run_made_elsewhere_keeps_its_document_data() {
use cmd::plugin::FilterRun;
let mut s = session();
let id = "test.engine.handindata";
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
let install = |s: &mut Session, src: String| {
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": true}));
};
// It first keeps the pens it found with the document.
install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string()));
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
// Given those pens, it adds one, in the background; the output handed in is marked, so the
// test sees it is the one that applied.
let next = json!({"objects": [{"path": triangle(), "name": "Pen 3"}], "documentData": {"pens": 3}}).to_string();
install(&mut s, tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":{"pens":2}"#, &next));
let p = json!({"id": id, "ids": []});
let r = FilterRun::prepare(&s, &p).unwrap();
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Pen 3", "Handed in");
r.hand_in(&mut s, output.into_bytes());
let undo = s.doc().unwrap().history.undo.len();
let made = run(&mut s, "plugin.run", p)["ids"].clone();
let st = s.doc().unwrap();
assert_eq!(st.doc.node(NodeId(made[0].as_u64().unwrap())).unwrap().name.as_deref(), Some("Handed in"));
assert_eq!(st.doc.plugin_data(id), Some(&json!({"pens": 3})));
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects");
}
/// A plug-in of API level 2 that uses the host imports lists `progress`, which the user grants like
/// any permission; a filter run made elsewhere then reports its progress, as a share, to the
/// progress the caller shows, and sees the caller's Cancel.
#[test]
fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() {
use crate::cmd::fileio::progress::{self, Progress};
use crate::cmd::plugin::FilterRun;
let mut s = session();
let id = "test.engine.level2";
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}}"#);
let wasm = wat::parse_str(tpl::cooperating(&manifest, 1000, r#"{"objects":[]}"#, r#"{"objects":[]}"#)).unwrap();
let data = vectorcraft_format::base64_encode(&wasm);
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data}));
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read", "doc.write", "progress"])));
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})).unwrap_err().to_string();
assert!(e.contains("asks for the permissions doc.read, doc.write, progress"), "{e}");
run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write", "progress"]}));
let shown = Progress::new();
let r = FilterRun::prepare(&s, &json!({"id": id})).unwrap();
assert!(progress::scope(&shown, || r.run()).is_ok());
assert_eq!((shown.counts(), shown.counts_share()), ((999, 1000), true));
// Cancelled, it stops on its first step, and what it returns then is discarded.
shown.cancel();
let cancelled = progress::scope(&shown, || r.run());
assert!(matches!(cancelled, Err(EngineError::Cancelled)), "{cancelled:?}");
assert_eq!(shown.counts(), (0, 1000));
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
/// two slices of its budget and changes nothing.
#[test]
fn a_cancelled_filter_run_changes_nothing() {
let mut s = session();
let id = "test.engine.cancelled";
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1 << 40)), "allowHeavy": true}));
let before = s.doc().unwrap().doc.clone();
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("plugin.run", &json!({"id": id})));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert_eq!(s.doc().unwrap().doc, before);
}
/// The manifest of a filter `id` named "Cmd Test" of `kind` with `commands` (a JSON list).
fn with_commands(id: &str, kind: &str, commands: &str) -> String {
format!(r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"{kind}","commands":{commands}}}"#)
}
/// Two commands: Grow, with a parameter, and Tidy, whose `menu` names another menu.
const GROW_AND_TIDY: &str = r#"[{"id":"grow","label":"Grow","params":{"by":{"type":"number","min":0,"max":10,"default":2}}},
{"id":"tidy","label":"Tidy","menu":"File"}]"#;
/// A plug-in that adds a triangle when it runs as its command `grow` (`_context.command`) and
/// returns its input unchanged otherwise.
fn grower(id: &str) -> String {
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
tpl::when_given(&with_commands(id, "filter", GROW_AND_TIDY), tpl::Given::Params, r#""command":"grow""#, triangle)
}
/// The command listing's entry for `id`.
fn listed(s: &Session, id: &str) -> Option<crate::cmd::CommandInfo> {
s.commands().into_iter().find(|c| c.id == id)
}
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()
}
/// A plug-in's commands are commands of their own while it is installed: listed under Object ›
/// Plug-ins › its name whatever menu the manifest names, run with their checked parameters and
/// `_context.command` as one undo step named after them, and gone once it is removed or replaced
/// by a module without them.
#[test]
fn plugin_commands_are_registered_run_and_removed() {
let mut s = session();
let id = "test.engine.cmds";
let (grow, tidy) = ("plugin.test.engine.cmds.grow", "plugin.test.engine.cmds.tidy");
let info = install_wat(&mut s, &grower(id));
assert_eq!(info["commands"][0]["command"], grow);
assert_eq!(info["commands"][1]["label"], "Tidy");
for (cmd, label) in [(grow, "Grow"), (tidy, "Tidy")] {
let c = listed(&s, cmd).unwrap_or_else(|| panic!("{cmd} is listed"));
assert_eq!((c.label, c.menu.as_slice()), (label, &["Object", "Plug-ins", "Cmd Test"][..]), "{cmd}");
assert!(c.enabled, "{cmd}");
}
assert!(crate::cmd::find_command(grow).is_some());
run(&mut s, "select.none", json!({}));
let (before, undo) = (objects(&s), s.doc().unwrap().history.undo.len());
let r = run(&mut s, grow, json!({"by": 3}));
assert_eq!((r["plugin"].as_str(), r["command"].as_str()), (Some(id), Some("grow")));
assert_eq!(objects(&s), before + 1, "the command added its triangle");
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Grow"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(objects(&s), before);
// Its parameters are checked before it runs.
let e = s.execute(grow, &json!({"by": "lots"})).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == grow), "{e}");
assert_eq!(objects(&s), before);
// Another command of the same plug-in is told apart by its id.
run(&mut s, tidy, json!({}));
assert_eq!(objects(&s), before, "tidy returns its input unchanged");
// Replaced by a module without commands, its commands are gone; installed again, back.
install_wat(&mut s, &tpl::module(&tpl::manifest(id, "filter", "{}"), tpl::ECHO, "", 1));
assert!(listed(&s, grow).is_none() && crate::cmd::find_command(grow).is_none());
install_wat(&mut s, &grower(id));
assert!(listed(&s, grow).is_some());
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(listed(&s, grow).is_none() && listed(&s, tidy).is_none());
assert!(s.execute(grow, &json!({})).is_err(), "removed, it no longer runs");
}
/// A command run is journaled like any other, so an action records it with its parameters and
/// plays it back.
#[test]
fn actions_record_and_play_plugin_commands() {
let mut s = session();
let id = "test.engine.cmds.actions";
let grow = crate::cmd::plugin::command_id(id, "grow");
install_wat(&mut s, &grower(id));
run(&mut s, "select.none", json!({}));
let before = objects(&s);
let start = s.journal.len();
run(&mut s, &grow, json!({"by": 4}));
let steps = s.journal_for_action(start);
assert_eq!(steps, [(grow.clone(), json!({"by": 4}))]);
run(&mut s, "edit.undo", json!({}));
let commands: Vec<Value> = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect();
// The triangle a run adds is selected, and the next run would be given it: deselect between.
let none = json!({"command": "select.none", "params": {}});
run(&mut s, "command.batch", json!({"label": "Grow Twice", "commands": [commands[0].clone(), none, commands[0].clone()]}));
assert_eq!(objects(&s), before + 2, "both steps ran");
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A command that traps leaves the document and its history as they were, and the error names
/// the plug-in and the command.
#[test]
fn a_failing_plugin_command_changes_nothing_and_names_itself() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let id = "test.engine.cmds.trap";
install_wat(&mut s, &tpl::module(&with_commands(id, "filter", r#"[{"id":"boom","label":"Boom"}]"#), "unreachable", "", 1));
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute(&crate::cmd::plugin::command_id(id, "boom"), &json!({})).unwrap_err().to_string();
assert!(e.contains("“Cmd Test” › Boom: ") && e.contains("trapped"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A command that runs out of its instruction budget leaves the document and its history as they
/// were, and the error names the plug-in and the command.
#[test]
fn a_plugin_command_out_of_budget_changes_nothing_and_names_itself() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let id = "test.engine.cmds.spin";
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
// More steps than a light budget pays for (as the plug-in crate's budget test counts them).
install_wat(&mut s, &tpl::spinning(&with_commands(id, "filter", r#"[{"id":"spin","label":"Spin"}]"#), 100_000_000, triangle));
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute(&crate::cmd::plugin::command_id(id, "spin"), &json!({})).unwrap_err().to_string();
assert!(e.contains("“Cmd Test” › Spin: ") && e.contains("instruction budget"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// An `ids` that is not a list, or that holds an entry that is not an object id, is an error naming
/// `ids` for a plug-in command and for `plugin.run`, and is never read as the selection; left out
/// or null, `ids` is the selection.
#[test]
fn a_malformed_ids_is_refused_not_read_as_the_selection() {
let mut s = session();
let id = "test.engine.cmds.ids";
let grow = crate::cmd::plugin::command_id(id, "grow");
install_wat(&mut s, &grower(id));
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let cases = [
(grow.as_str(), json!(5)),
(grow.as_str(), json!([1, "x"])),
(grow.as_str(), json!([-1])),
("plugin.run", json!("all")),
("plugin.run", json!([1.5])),
];
for (cmd, ids) in cases {
let mut p = json!({"ids": ids});
if cmd == "plugin.run" {
p["id"] = json!(id);
}
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd: c, .. } if c == cmd), "{cmd} {ids}: {e}");
let said = e.to_string();
assert!(
said.contains("ids must be an array of non-negative integer object ids") || said.contains("not a non-negative integer object id"),
"{cmd} {ids}: {e}"
);
assert_eq!(s.doc().unwrap().doc, before, "{cmd} {ids}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {ids}");
}
// Left out or null, the command runs on the selection.
for p in [json!({}), json!({"ids": null})] {
let r = run(&mut s, &grow, p.clone());
assert_eq!(r["command"], "grow", "{p}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc, before, "{p}");
}
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A live effect's commands are listed under Effect › Plug-ins and run under its permissions (with
/// commands it gets `doc.write` unless its list leaves it out), and labels show bidirectional
/// characters escaped. A malformed
/// `commands` entry is refused at install, and the error names it.
#[test]
fn effect_commands_labels_and_malformed_entries() {
let mut s = session();
let id = "test.engine.cmds.effect";
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
install_wat(&mut s, &tpl::returning(&with_commands(id, "effect", "[{\"id\":\"flip\",\"label\":\"Flip\u{202E}txt\"}]"), triangle));
let c = listed(&s, &crate::cmd::plugin::command_id(id, "flip")).expect("listed");
assert_eq!((c.label, c.menu.as_slice()), ("Flip\\u{202E}txt", &["Effect", "Plug-ins", "Cmd Test"][..]));
run(&mut s, "select.none", json!({}));
// Without `permissions`, a live effect with commands may change the document: its command adds
// the triangle, as one undo step.
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let out = run(&mut s, &crate::cmd::plugin::command_id(id, "flip"), json!({}));
assert_eq!(out["ids"].as_array().map(Vec::len), Some(1), "{out}");
assert_ne!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
run(&mut s, "plugin.remove", json!({"id": id}));
// One that lists its permissions without `doc.write` is refused the change, and the document
// stays as it was.
let listed = format!(
r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"effect","permissions":["doc.read","effect.render"],"commands":[{{"id":"flip","label":"Flip"}}]}}"#
);
let wasm = wat::parse_str(tpl::returning(&listed, triangle)).unwrap();
let hash = vectorcraft_plugins::permissions::sha256_hex(&wasm);
let grant = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read", "effect.render"], "hash": hash});
run(&mut s, "plugin.install", grant);
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute(&crate::cmd::plugin::command_id(id, "flip"), &json!({})).unwrap_err().to_string();
assert!(e.contains("wasn't granted `doc.write`"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
run(&mut s, "plugin.remove", json!({"id": id}));
let bad = id.replace("effect", "bad");
let wasm = wat::parse_str(tpl::module(&with_commands(&bad, "filter", r#"[{"id":"ok","label":"A"},{"id":"Bad","label":"B"}]"#), tpl::ECHO, "", 1));
let e = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm.unwrap())})).unwrap_err().to_string();
assert!(e.contains("command `Bad`"), "{e}");
assert!(vectorcraft_plugins::registry::get(&bad).is_none());
}
/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first`
/// (instructions that trap unless each storage call returns what it should), with the keys "zoom"
/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100.
fn storing(id: &str, first: &str) -> Vec<u8> {
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS);
wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap()
}
/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile".
const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable))
(if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))";
/// Traps unless "zoom" is "hello".
const READS: &str =
"(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable))
(if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))";
fn install_storing(s: &mut Session, id: &str, first: &str) -> Value {
let data = vectorcraft_format::base64_encode(&storing(id, first));
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"]}))
}
/// [`install_storing`] in place of another module with the same id (`replace: true`).
fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value {
let data = vectorcraft_format::base64_encode(&storing(id, first));
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}))
}
/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session
/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache`
/// with its id empties its cache alone and `plugin.remove` deletes both. The caps and the bytes
/// each call is charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`],
/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test.
/// `plugin.clearCache` without an id empties the cache of every plug-in installed in the process,
/// so it is tested in a binary of its own (`tests/plugin_clear_cache.rs`).
#[test]
fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() {
let folder = crate::tests_links::Folder::new("plugin-storage");
let (id, other) = ("test.engine.storing", "test.engine.storing.other");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_storing(&mut s, id, WRITES);
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
let dir = crate::store::plugin_data_dir(&folder.0, id);
assert!(dir.join("settings.json").is_file());
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3));
// A restart: installed again from the store, and replaced by a module that reads.
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
let listed = run(&mut next, "plugin.list", json!({}));
let entry = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap();
assert_eq!(entry["cacheBytes"], json!(3));
assert_eq!(replace_storing(&mut next, id, READS)["cacheBytes"], json!(3));
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
// Another plug-in has settings of its own: "zoom" isn't set there.
install_storing(&mut next, other, READS);
assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err());
// Clearing the cache leaves the settings, and the other plug-in's cache.
replace_storing(&mut next, other, WRITES);
run(&mut next, "plugin.run", json!({"id": other, "ids": []}));
assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3}));
assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(3));
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err());
run(&mut next, "plugin.remove", json!({"id": id}));
assert!(!dir.exists(), "removed with the plug-in");
run(&mut next, "plugin.remove", json!({"id": other}));
plugin_storage_is_capped_and_survives_a_damaged_settings_file();
plugin_storage_calls_are_charged_the_files_they_read_and_write();
}
/// A `storage` folder in the plug-in store that is a link leads out of the store: the plug-in
/// installs without `replace` although a folder for its id is there, its storage calls fail as
/// unavailable, and removing the plug-in deletes nothing the link leads to.
#[test]
fn plugin_storage_behind_a_link_is_left_alone() {
let folder = crate::tests_links::Folder::new("plugin-storage-link");
let outside = crate::tests_links::Folder::new("plugin-storage-outside");
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&outside.0, folder.0.join("storage")).is_ok();
// Without Developer Mode or elevation Windows makes no symbolic link, but a junction.
#[cfg(windows)]
let made = std::os::windows::fs::symlink_dir(&outside.0, folder.0.join("storage")).is_ok()
|| std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(folder.0.join("storage"))
.arg(&outside.0)
.output()
.is_ok_and(|o| o.status.success());
#[cfg(not(any(unix, windows)))]
let made = false;
if !made {
eprintln!("skipped: symbolic links can't be made here");
return;
}
let id = "test.engine.storagelink";
let kept = crate::store::plugin_data_dir(&folder.0, id).join("settings.json");
std::fs::create_dir_all(kept.parent().unwrap()).unwrap();
std::fs::write(&kept, b"{}").unwrap();
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_storing(&mut s, id, "");
let st = vectorcraft_plugins::registry::storage(id).unwrap();
let m = &mut vectorcraft_plugins::storage::Meter::unlimited();
assert_eq!(st.set_setting("zoom", b"hello", m), Err(vectorcraft_plugins::storage::StorageError::Unavailable));
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(kept.is_file(), "left where the link leads");
}
/// `plugin.remove` with the id of a plug-in that isn't installed deletes the settings and cache the
/// plug-in store still keeps for it; with neither, it fails as before.
#[test]
fn removing_an_id_that_is_not_installed_deletes_its_storage() {
let folder = crate::tests_links::Folder::new("plugin-storage-orphan");
let id = "test.engine.storageorphan";
let mut s = session();
s.attach_plugin_store(folder.0.clone());
let dir = crate::store::plugin_data_dir(&folder.0, id);
std::fs::create_dir_all(dir.join("cache")).unwrap();
std::fs::write(dir.join("settings.json"), b"{}").unwrap();
std::fs::write(dir.join("cache").join("tile.bin"), b"abc").unwrap();
assert!(vectorcraft_plugins::registry::get(id).is_none());
assert_eq!(run(&mut s, "plugin.remove", json!({"id": id})), json!({"removed": id}));
assert!(!dir.exists(), "the storage of an id that isn't installed is deleted");
assert!(s.execute("plugin.remove", &json!({"id": id})).is_err(), "nothing left to remove");
}
/// The settings file and the cache are checked: a file that doesn't read is moved aside and the
/// settings start empty; a write past a cap fails and changes nothing; cache entries whose names
/// differ only in case are two files; a removed plug-in's storage takes no more writes.
fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() {
use vectorcraft_plugins::storage::{self, Meter, StorageError};
let folder = crate::tests_links::Folder::new("plugin-storage-caps");
let id = "test.engine.storagecaps";
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_storing(&mut s, id, "");
let st = vectorcraft_plugins::registry::storage(id).unwrap();
let dir = crate::store::plugin_data_dir(&folder.0, id);
std::fs::create_dir_all(&dir).unwrap();
let m = &mut Meter::unlimited();
// A truncated settings file.
std::fs::write(dir.join("settings.json"), br#"{"zoom": "aGVsbG"#).unwrap();
assert_eq!(st.setting("zoom", m).unwrap(), None);
assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists());
st.set_setting("zoom", b"hello", m).unwrap();
assert_eq!(st.setting("zoom", m).unwrap(), Some(b"hello".to_vec()));
// Damaged again, it replaces the file moved aside before.
std::fs::write(dir.join("settings.json"), b"[").unwrap();
assert_eq!(st.setting("zoom", m).unwrap(), None);
assert_eq!(std::fs::read(dir.join("settings.unreadable.json")).unwrap(), b"[");
st.set_setting("zoom", b"hello", m).unwrap();
plugin_storage_writes_are_atomic(&*st, &dir, &folder.0);
// At most 256 keys: a new one is refused, an existing one still changes.
let keys: std::collections::BTreeMap<String, String> = (0..storage::MAX_SETTINGS).map(|i| (format!("k{i}"), "eA==".to_string())).collect();
std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap();
assert_eq!(st.set_setting("one-more", b"x", m), Err(StorageError::Full));
st.set_setting("k0", b"y", m).unwrap();
assert_eq!(st.setting("k0", m).unwrap(), Some(b"y".to_vec()));
plugin_storage_skips_oversize_files(&*st, &dir);
// At most 1 MiB in all: eleven values of 64 KiB fit, a twelfth doesn't, and nothing changes.
let big = vec![7_u8; storage::MAX_SETTING_BYTES];
let keys: std::collections::BTreeMap<String, String> = (0..11).map(|i| (format!("k{i}"), vectorcraft_format::base64_encode(&big))).collect();
std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap();
assert_eq!(st.set_setting("k11", &big, m), Err(StorageError::Full));
assert_eq!((st.setting("k11", m).unwrap(), st.setting("k0", m).unwrap()), (None, Some(big.clone())));
// The cache: entry size, then the total, here nearly reached by a file already there.
assert_eq!(st.set_cache_entry("big", &vec![0; storage::MAX_CACHE_ENTRY + 1], m), Err(StorageError::Full));
std::fs::create_dir_all(dir.join("cache")).unwrap();
std::fs::File::create(dir.join("cache").join("filler.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES - 2).unwrap();
// A temporary file a failed write left behind counts toward no cap, and clearing removes it.
let leftover = dir.join("cache").join(".tile.bin.1-0.tmp");
std::fs::write(&leftover, b"tmp").unwrap();
assert_eq!(st.set_cache_entry("tile", b"abc", m), Err(StorageError::Full));
assert_eq!(st.cache_entry("tile", m).unwrap(), None);
st.set_cache_entry("tile", b"ab", m).unwrap();
assert_eq!(st.cache_bytes(), storage::MAX_CACHE_BYTES);
assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3);
assert!(!leftover.exists());
// Names differing in case are two entries.
st.set_cache_entry("Tile", b"1", m).unwrap();
st.set_cache_entry("tile", b"22", m).unwrap();
assert_eq!((st.cache_entry("Tile", m).unwrap(), st.cache_entry("tile", m).unwrap()), (Some(b"1".to_vec()), Some(b"22".to_vec())));
assert_eq!(st.cache_bytes(), 3);
// More items in the cache folder than a write looks at: the write is refused whatever the
// items it looked at hold, and clearing still reaches every entry.
let cache = dir.join("cache");
for i in 0..crate::store::MAX_CACHE_SCAN {
std::fs::create_dir(cache.join(format!("d{i:05}"))).unwrap();
}
std::fs::File::create(cache.join("z.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES).unwrap();
assert_eq!(st.set_cache_entry("more", b"1", m), Err(StorageError::Full));
assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3);
assert!(!cache.join("z.bin").exists());
// An id that differs only in case has storage of its own, which removing this one leaves.
let upper_id = "test.engine.StorageCaps";
s.attach_plugin_storage(upper_id);
let upper = vectorcraft_plugins::registry::storage(upper_id).unwrap();
assert_eq!(upper.setting("k0", m).unwrap(), None);
upper.set_setting("k0", b"upper", m).unwrap();
assert_eq!(st.setting("k0", m).unwrap(), Some(big));
// So do ids whose `_` and `.` could be read as each other's escapes.
let folders: std::collections::BTreeSet<_> =
["a.b", "a_0b", "a_b", "A.b", "a__b", "a._b", "a_.b"].iter().map(|id| crate::store::plugin_data_dir(&folder.0, id)).collect();
assert_eq!(folders.len(), 7, "{folders:?}");
// Removing the plug-in closes the storage a run still holds, even one from before the plug-in
// was replaced, so nothing is written back.
s.attach_plugin_storage(id);
run(&mut s, "plugin.remove", json!({"id": id}));
assert_eq!(st.set_setting("zoom", b"again", m), Err(StorageError::Unavailable));
assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable));
assert!(!dir.exists());
assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec()));
s.remove_plugin_storage(upper_id).unwrap();
}
/// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although
/// each of its values would fit, is moved aside and the settings start empty; a cache entry file
/// larger than an entry may be fails as unavailable. `st` is the storage in `dir`.
fn plugin_storage_skips_oversize_files(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path) {
use vectorcraft_plugins::storage::{self, Meter, StorageError};
let m = &mut Meter::unlimited();
let value = vectorcraft_format::base64_encode(&vec![7_u8; storage::MAX_SETTING_BYTES]);
let keys: std::collections::BTreeMap<String, String> = (0..13).map(|i| (format!("k{i}"), value.clone())).collect();
let oversize = serde_json::to_vec(&keys).unwrap();
assert!(oversize.len() > storage::MAX_SETTINGS_BYTES);
std::fs::write(dir.join("settings.json"), &oversize).unwrap();
assert_eq!(st.setting("k0", m).unwrap(), None);
assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists());
std::fs::create_dir_all(dir.join("cache")).unwrap();
let huge = dir.join("cache").join("huge.bin");
std::fs::File::create(&huge).unwrap().set_len(storage::MAX_CACHE_ENTRY as u64 + 1).unwrap();
assert_eq!(st.cache_entry("huge", m), Err(StorageError::Unavailable));
std::fs::remove_file(&huge).unwrap();
}
/// Writes are atomic: a new file takes the old one's name, so another name for the old file (a hard
/// link) keeps its content. `st` is the storage in `dir` and holds the setting "zoom"; `outside` is
/// a folder outside it.
fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path, outside: &std::path::Path) {
let m = &mut vectorcraft_plugins::storage::Meter::unlimited();
let settings_copy = outside.join("settings-copy.json");
let before = std::fs::read(dir.join("settings.json")).unwrap();
std::fs::hard_link(dir.join("settings.json"), &settings_copy).unwrap();
st.set_setting("zoom", b"world", m).unwrap();
assert_eq!(std::fs::read(&settings_copy).unwrap(), before);
assert_eq!(st.setting("zoom", m).unwrap(), Some(b"world".to_vec()));
st.set_cache_entry("tile", b"abc", m).unwrap();
let entry_copy = outside.join("tile-copy.bin");
let entry = std::fs::read_dir(dir.join("cache")).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_file()).unwrap();
std::fs::hard_link(&entry, &entry_copy).unwrap();
st.set_cache_entry("tile", b"xyz", m).unwrap();
assert_eq!(std::fs::read(&entry_copy).unwrap(), b"abc");
assert_eq!(st.cache_entry("tile", m).unwrap(), Some(b"xyz".to_vec()));
assert!(st.remove_cache_entry("tile").unwrap());
std::fs::remove_file(&settings_copy).unwrap();
std::fs::remove_file(&entry_copy).unwrap();
}
/// Gives the file at `path` the modified time `when`, whatever the file system's clock says.
fn set_modified(path: &std::path::Path, when: std::time::SystemTime) {
std::fs::File::options().write(true).open(path).unwrap().set_modified(when).unwrap();
}
/// Each storage call is charged the bytes it reads and writes, before it does: a settings call the
/// whole settings file when it reads the file and the whole file when it writes it, a cache call the
/// entry it reads or writes. A call that would pass what its meter has left fails and changes nothing.
fn plugin_storage_calls_are_charged_the_files_they_read_and_write() {
use vectorcraft_plugins::storage::{Meter, StorageError};
let folder = crate::tests_links::Folder::new("plugin-storage-meter");
let id = "test.engine.storagemeter";
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_storing(&mut s, id, "");
let st = vectorcraft_plugins::registry::storage(id).unwrap();
let file = crate::store::plugin_data_dir(&folder.0, id).join("settings.json");
let size = || std::fs::metadata(&file).unwrap().len();
st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap();
st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap();
// A file another window changed is read whole, even for a one-byte setting.
let touch = || set_modified(&file, std::fs::metadata(&file).unwrap().modified().unwrap() + std::time::Duration::from_secs(1));
touch();
let before = size();
let mut m = Meter::unlimited();
assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec()));
assert_eq!(m.used(), before);
// Setting it writes the file again, and so does deleting one.
let mut m = Meter::unlimited();
st.set_setting("small", b"yz", &mut m).unwrap();
assert_eq!(m.used(), size());
let mut m = Meter::unlimited();
assert!(st.delete_setting("small", &mut m).unwrap());
assert_eq!(m.used(), size());
st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap();
// Less left than the file: nothing is read. Enough to read it but not to write it again:
// nothing changes.
touch();
let mut m = Meter::new(size() - 1);
assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget));
assert_eq!(m.used(), 0);
assert_eq!(st.set_setting("small", b"z", &mut Meter::new(size())), Err(StorageError::Budget));
assert_eq!(st.setting("small", &mut Meter::unlimited()).unwrap(), Some(b"y".to_vec()));
// A cache write is charged the entry it writes, a read the entry it reads.
let mut m = Meter::unlimited();
st.set_cache_entry("tile", &[1; 1000], &mut m).unwrap();
assert_eq!(m.used(), 1000);
let mut m = Meter::unlimited();
assert_eq!(st.cache_entry("tile", &mut m).unwrap().map(|v| v.len()), Some(1000));
assert_eq!(m.used(), 1000);
assert_eq!(st.cache_entry("tile", &mut Meter::new(999)), Err(StorageError::Budget));
assert_eq!(st.set_cache_entry("tile", &[2; 10], &mut Meter::new(9)), Err(StorageError::Budget));
assert_eq!(st.cache_entry("tile", &mut Meter::unlimited()).unwrap().map(|v| v.len()), Some(1000));
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A session with the plug-in `id` installed in a new store → its storage and the path of its
/// settings file.
fn storage_in(
s: &mut Session,
folder: &crate::tests_links::Folder,
id: &str,
) -> (std::sync::Arc<dyn vectorcraft_plugins::storage::Storage>, std::path::PathBuf) {
s.attach_plugin_store(folder.0.clone());
install_storing(s, id, "");
(vectorcraft_plugins::registry::storage(id).unwrap(), crate::store::plugin_data_dir(&folder.0, id).join("settings.json"))
}
/// The settings stay in memory while the file keeps its size and modified time: a read doesn't open
/// the file, a set to the value already there doesn't write it, a real change is in the file when
/// the call returns, and a file with another time is read again.
#[test]
fn plugin_settings_are_read_from_memory_until_the_file_changes() {
use vectorcraft_plugins::storage::{Meter, StorageError};
let folder = crate::tests_links::Folder::new("plugin-storage-kept");
let id = "test.engine.storagekept";
let mut s = session();
let (st, file) = storage_in(&mut s, &folder, id);
st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap();
let written = std::fs::metadata(&file).unwrap().modified().unwrap();
// Other bytes of the same size and the same time: the storage can't tell, so what it answers
// shows that it did not read the file.
let other =
std::fs::read_to_string(&file).unwrap().replace(&vectorcraft_format::base64_encode(b"hello"), &vectorcraft_format::base64_encode(b"world"));
std::fs::write(&file, &other).unwrap();
set_modified(&file, written);
let mut m = Meter::unlimited();
assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec()));
assert_eq!(m.used(), 0, "answered from memory");
// Setting the value it has writes nothing: the file still holds the other bytes.
let mut m = Meter::unlimited();
st.set_setting("zoom", b"hello", &mut m).unwrap();
assert_eq!(m.used(), 0, "nothing to write");
assert_eq!(std::fs::read_to_string(&file).unwrap(), other);
// Another time: read again.
set_modified(&file, written + std::time::Duration::from_secs(1));
let mut m = Meter::unlimited();
assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"world".to_vec()));
assert_eq!(m.used(), other.len() as u64);
// A change reaches the file before the call returns, and a failed one is not kept.
st.set_setting("tile", b"x", &mut Meter::unlimited()).unwrap();
assert!(std::fs::read_to_string(&file).unwrap().contains("\"tile\""));
assert_eq!(st.set_setting("tile", b"y", &mut Meter::new(0)), Err(StorageError::Budget));
assert_eq!(st.setting("tile", &mut Meter::unlimited()).unwrap(), Some(b"x".to_vec()));
assert!(st.delete_setting("tile", &mut Meter::unlimited()).unwrap());
assert!(!std::fs::read_to_string(&file).unwrap().contains("\"tile\""));
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// `plugin.remove` drops what was kept: a file put back with the removed settings' size and time is
/// read from disk by the storage of the id installed again.
#[test]
fn removing_a_plugin_drops_its_settings_from_memory() {
use vectorcraft_plugins::storage::Meter;
let folder = crate::tests_links::Folder::new("plugin-storage-kept-removed");
let id = "test.engine.storagekeptremoved";
let mut s = session();
let (st, file) = storage_in(&mut s, &folder, id);
st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap();
let (bytes, written) = (std::fs::read(&file).unwrap(), std::fs::metadata(&file).unwrap().modified().unwrap());
run(&mut s, "plugin.remove", json!({"id": id}));
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, &bytes).unwrap();
set_modified(&file, written);
s.attach_plugin_storage(id);
let st = vectorcraft_plugins::registry::storage(id).unwrap();
let mut m = Meter::unlimited();
assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec()));
assert_eq!(m.used(), bytes.len() as u64, "read from the file");
s.remove_plugin_storage(id).unwrap();
}
/// The cache's total stays in memory while the cache folder keeps its time; a write and a clear
/// update it, and removing an entry drops it. A file whose length changes behind the storage leaves
/// the folder's time alone, so the total it reports shows that it did not list the folder.
#[test]
fn plugin_cache_total_is_kept_and_follows_writes_removals_and_clears() {
use vectorcraft_plugins::storage::Meter;
let folder = crate::tests_links::Folder::new("plugin-storage-kept-cache");
let id = "test.engine.storagekeptcache";
let mut s = session();
let (st, file) = storage_in(&mut s, &folder, id);
let cache = file.parent().unwrap().join("cache");
let m = &mut Meter::unlimited();
let grow = |name: &str, len: u64| std::fs::File::options().write(true).open(cache.join(name)).unwrap().set_len(len).unwrap();
st.set_cache_entry("tile", b"abc", m).unwrap();
grow("tile.bin", 10);
assert_eq!(st.cache_bytes(), 3, "the write set the total");
// A write lists the folder for its caps, and starts from what it finds.
st.set_cache_entry("more", b"12345", m).unwrap();
grow("more.bin", 5);
assert_eq!(st.cache_bytes(), 15);
assert!(st.remove_cache_entry("more").unwrap());
assert_eq!(st.cache_bytes(), 10, "after a removal the total comes from the folder");
grow("tile.bin", 20);
assert_eq!(st.cache_bytes(), 10, "and is kept again");
assert_eq!(st.clear_cache().unwrap(), 20);
assert_eq!(st.cache_bytes(), 0);
// A folder deleted behind the storage is noticed.
st.set_cache_entry("tile", b"abc", m).unwrap();
std::fs::remove_dir_all(&cache).unwrap();
assert_eq!(st.cache_bytes(), 0);
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A module with other bytes than the plug-in installed with its id, or, with none installed, one
/// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and
/// then keeps them; the same module installs again without it, and `replace` must be true or false.
#[test]
fn another_module_under_an_id_with_settings_needs_replace() {
let folder = crate::tests_links::Folder::new("plugin-storage-replace");
let id = "test.engine.storingreplace";
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_storing(&mut s, id, WRITES);
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
let hash = run(&mut s, "plugin.info", json!({"id": id}))["hash"].clone();
// The same module again.
install_storing(&mut s, id, WRITES);
let reads = vectorcraft_format::base64_encode(&storing(id, READS));
let install = |s: &mut Session, extra: Value| {
let mut p = json!({"dataBase64": reads, "grant": ["settings", "cache"]});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("plugin.install", &p)
};
let Err(e) = install(&mut s, json!({})) else { panic!("another module took the plug-in's place") };
assert!(e.to_string().contains(&format!("“Test {id}” is another module than the plug-in with id \"{id}\"")), "{e}");
assert!(e.to_string().contains("`replace: true`"), "{e}");
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(true));
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["hash"], hash);
for bad in [json!({"replace": "yes"}), json!({"replace": 1})] {
let Err(e) = install(&mut s, bad) else { panic!("a bad `replace` was taken") };
assert!(e.to_string().contains("`replace` must be true or false"), "{e}");
}
assert!(install(&mut s, json!({"replace": false})).is_err());
install(&mut s, json!({"replace": true})).unwrap();
// It reads what the one it replaced set.
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3));
// Not installed, but with settings in the store: another module needs `replace` too.
vectorcraft_plugins::registry::remove(id);
let writes = vectorcraft_format::base64_encode(&storing(id, WRITES));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": writes}))["needsReplace"], json!(true));
assert!(s.execute("plugin.install", &json!({"dataBase64": writes, "grant": ["settings", "cache"]})).is_err());
run(&mut s, "plugin.install", json!({"dataBase64": writes, "grant": ["settings", "cache"], "replace": true}));
run(&mut s, "plugin.remove", json!({"id": id}));
// Once removed, with its settings and cache, it installs as a first install does.
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(false));
install(&mut s, json!({})).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
}