Files
notescraft/crates/engine/tests/core_commands.rs
T
Marcos Paulo c78fd488cc
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: registry core (model, guard, verify, store, net, install, commands), registry browser UI, signing tool, size profile and font dedupe, lazy Hebrew/Arabic fonts, htmlmd/srs/textdiff crates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 17:28:10 -03:00

583 lines
21 KiB
Rust

//! Integration tests of the core commands through [`Registry::run`]: every id is
//! reachable, the params are validated and the errors are data.
mod common;
use common::{on_disk, seed, temp_config, temp_vault};
use notescraft_engine::{CommandError, Error, Registry, Session, adversarial_params, register_core};
use serde_json::{Value, json};
/// Every id the core registers.
const CORE_IDS: &[&str] = &[
"base.create",
"base.list",
"base.properties",
"base.query",
"base.read",
"base.resolve",
"base.row.set",
"base.view.add",
"base.view.duplicate",
"base.view.remove",
"base.view.rename",
"base.view.reorder",
"base.write",
"batch.apply",
"batch.cancel",
"batch.decide",
"batch.proposal",
"batch.proposals",
"batch.start",
"batch.status",
"batch.step",
"block.delete",
"block.duplicate",
"block.list",
"block.move",
"block.turn_into",
"grammar.info",
"grammar.install",
"grammar.install_text",
"grammar.remove",
"registry.check",
"registry.disable",
"registry.enable",
"registry.info",
"registry.install",
"registry.list",
"registry.remove",
"registry.rollback",
"registry.suggest",
"registry.update",
"json.format_block",
"json.minify_block",
"vault.info",
"note.list",
"note.read",
"note.write",
"note.create",
"note.delete",
"note.extract",
"history.list",
"history.read",
"trash.empty",
"workspace.delete",
"workspace.list",
"workspace.load",
"workspace.save",
"trash.list",
"trash.restore",
"note.merge",
"note.properties",
"note.property_remove",
"note.property_rename",
"note.property_set",
"properties.keys",
"properties.rename_all",
"properties.values",
// The decorations of the notes: the icon, the tint and the banner (D3).
"decor.list",
"decor.set",
"note.rename",
"note.duplicate",
"folder.create",
"folder.rename",
"folder.delete",
"links.outgoing",
"links.backlinks",
"links.unresolved",
"tags.list",
"tags.rename",
"tags.merge",
"buffer.open",
"buffer.edit",
"buffer.save",
"buffer.undo",
"buffer.redo",
// Slice 3: search, daily notes and templates, themes.
"search",
"switcher.search",
"note.random",
"note.unique",
"daily.open",
"note.periodic",
"calendar.month",
"calendar.agenda",
"calendar.next",
"calendar.free",
"calendar.conflicts",
"calendar.year",
"calendar.parse",
"calendar.review",
"calendar.filters",
"calendar.add",
"calendar.edit",
"calendar.move",
"calendar.delete",
"calendar.note",
"calendar.bulk",
"calendar.bulk_delete",
"calendar.filter_save",
"calendar.filter_delete",
"calendar.reminders",
"calendar.reminders_tick",
"calendar.reminder_snooze",
"calendar.reminder_dismiss",
"calendar.feeds",
"calendar.feed_add",
"calendar.feed_remove",
"calendar.sync_due",
"calendar.sync_apply",
"calendar.google_state",
"knowledge.context",
"knowledge.rebuild",
"index.pause",
"index.resume",
"knowledge.related",
"knowledge.search",
"knowledge.status",
"lint.check",
"lint.dismiss",
"lint.fix",
"lint.fix_all_safe",
"lint.rules",
"lint.state",
"lint.state_clear",
"lint.style",
"calendar.google_calendars",
"calendar.google_disconnect",
"template.list",
"template.create_note",
"template.insert",
"theme.list",
"theme.get",
"theme.set",
// The settings document (SPEC 6 item 14).
"settings.get",
"settings.set",
"settings.reset",
"editor.toggle_vim",
// The vault catalog behind the launcher (SPEC 19, task 1).
"vaults.list",
"note.capture",
"vaults.pin",
"vaults.rename",
"vaults.remove",
// The WebAssembly plugin host (SPEC 6 item 17).
"plugins.list",
"plugins.enable",
"plugins.disable",
"git.init",
"git.status",
"git.diff",
"git.stage",
"git.unstage",
"git.restore",
"git.commit",
"git.log",
"git.show",
"git.restore_version",
"bookmarks.add",
"bookmarks.remove",
"bookmarks.list",
"math.render",
// PDF export (SPEC 16).
"export.pdf",
// The pandoc export: enabled only when pandoc is installed.
"export.pandoc",
// Whiteboard SVG export (P12).
"file.export_whiteboard_svg",
// Attachments (SPEC 9).
"attachment.save",
"attachment.save_path",
// The slash menu (SPEC 21): the inserts, the 13 callout kinds and plumbing.
"slash.heading1",
"slash.heading2",
"slash.heading3",
"slash.bullet",
"slash.numbered",
"slash.task",
"slash.quote",
"slash.code",
"slash.divider",
"slash.link_note",
"slash.embed_note",
"slash.embed_image",
"slash.table",
"slash.callout",
"slash.callout.note",
"slash.callout.abstract",
"slash.callout.info",
"slash.callout.todo",
"slash.callout.tip",
"slash.callout.success",
"slash.callout.question",
"slash.callout.warning",
"slash.callout.failure",
"slash.callout.danger",
"slash.callout.bug",
"slash.callout.example",
"slash.callout.quote",
"slash.date",
"assistant.status",
"assistant.pending",
"assistant.accept",
"assistant.reject",
"assistant.grant_write",
"assistant.chat_append",
"assistant.chat_list",
"assistant.chat_read",
"assistant.chat_delete",
"slash.template",
"slash.canvas",
"slash.list",
"slash.trigger_ok",
// The sync worker and its conflict surface (SPEC 24).
"sync.pair",
"sync.status",
"sync.pending",
"sync.now",
"sync.unlink",
"sync.conflicts",
"sync.resolve",
// The server account, its vaults and the sharing surface (SPEC 19, spec 5-7).
"server.vaults",
"server.devices",
"server.history",
"server.revision",
"server.restore",
"server.create_vault",
"server.rename_vault",
"server.delete_vault",
"server.connect",
"share.members",
"share.set_role",
"share.remove",
"share.invite",
"share.invites",
"share.revoke_invite",
"share.accept",
// The published pages and the share links of the bound vault.
"publish.list",
"publish.enable",
"publish.disable",
"share.link.create",
"share.link.list",
"share.link.revoke",
// The live rooms of the co-editing surface (spec 2).
"live.join",
"live.edit",
"live.poll",
"live.peers",
"live.selection",
"live.info",
"live.leave",
"server.file_meta",
// The file viewers (spec `file-viewers`).
"file.info",
"file.hash",
"file.neighbours",
"image.annotations.load",
"image.annotations.save",
// The review comments of a note and their sidecar (spec comments-and-mentions).
"comment.list",
"comment.add",
"comment.reply",
"comment.edit",
"comment.delete",
"comment.resolve",
"comment.reanchor",
// The pending suggestions of a note (spec comments-and-mentions).
"suggestion.list",
"suggestion.accept",
"suggestion.reject",
// The activity feed and the mention list (spec comments-and-mentions).
"activity.list",
"activity.unread",
"activity.read",
"mention.candidates",
// The map tiles of the Maps feature (D13).
"maps.status",
"maps.clear_cache",
"maps.tile",
// The advanced URI links.
"uri.resolve",
];
/// A registry with the core commands and a writable session on a small vault.
fn fixture(name: &str) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
seed(
vault.path(),
&[
("root.md", "# Root\n\n[[notes/Child]] #work\n"),
("notes/Child.md", "# Child\n\n[back](../root.md#Root) ![[img.png]]\n"),
("img.png", "not really a png"),
("free.md", "[[nowhere]]\n"),
],
);
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let session = Session::open(vault.path()).expect("open the vault");
(reg, session, vault)
}
/// The `{ code, message }` a command reported.
fn code(result: &Result<Value, Error>) -> String {
match result {
Err(Error::Command { source, .. }) => source.code.clone(),
other => panic!("expected a command error, got {other:?}"),
}
}
/// Every core command is registered, described and enabled.
#[test]
fn every_core_command_is_registered_and_described() {
let (reg, session, _vault) = fixture("registered");
assert_eq!(reg.len(), CORE_IDS.len());
let listed = reg.list(&session);
let ids: Vec<&str> = listed.iter().map(|info| info.id.as_str()).collect();
let mut sorted: Vec<&str> = CORE_IDS.to_vec();
sorted.sort_unstable();
assert_eq!(ids, sorted, "sorted by id");
for info in &listed {
assert!(!info.doc.is_empty(), "{} has no doc", info.id);
if info.id != "export.pandoc" {
assert!(info.enabled, "{} should be enabled", info.id);
}
if info.id.starts_with("buffer.") && !matches!(info.id.as_str(), "buffer.open" | "buffer.undo" | "buffer.redo") {
assert!(info.params.iter().any(|param| param.required), "{} declares its params", info.id);
}
}
// Registering the core twice is refused, id by id.
let mut again = Registry::new();
register_core(&mut again).expect("register");
assert!(matches!(register_core(&mut again), Err(Error::Duplicate(id)) if id == "vault.info"));
}
/// The read commands answer with the data the UI and MCP expect.
#[test]
fn read_commands_answer_with_json() {
let (reg, mut session, _vault) = fixture("read");
let info = reg.run("vault.info", &mut session, &Value::Null).expect("vault.info");
assert_eq!(info.get("notes"), Some(&json!(3)));
assert_eq!(info.get("files"), Some(&json!(1)));
assert_eq!(info.get("tags"), Some(&json!(1)));
assert_eq!(info.get("read_only"), Some(&json!(false)));
assert!(info.get("root").and_then(Value::as_str).is_some_and(|root| !root.is_empty()));
let all = reg.run("note.list", &mut session, &Value::Null).expect("note.list");
assert_eq!(all.get("notes"), Some(&json!(["free.md", "notes/Child.md", "root.md"])));
let in_notes = reg.run("note.list", &mut session, &json!({ "folder": "notes" })).expect("note.list folder");
assert_eq!(in_notes.get("notes"), Some(&json!(["notes/Child.md"])));
let nothing = reg.run("note.list", &mut session, &json!({ "folder": "root" })).expect("a note is not a folder");
assert_eq!(nothing.get("notes"), Some(&json!([])));
let read = reg.run("note.read", &mut session, &json!({ "path": "root.md" })).expect("note.read");
assert_eq!(read.get("content"), Some(&json!("# Root\n\n[[notes/Child]] #work\n")));
let outgoing = reg.run("links.outgoing", &mut session, &json!({ "path": "free.md" })).expect("links.outgoing");
assert_eq!(outgoing.get("links").and_then(Value::as_array).map(Vec::len), Some(1));
assert_eq!(outgoing.get("links").and_then(|links| links.get(0)).and_then(|link| link.get("resolved")), Some(&json!(false)));
let backlinks = reg.run("links.backlinks", &mut session, &json!({ "path": "notes/Child.md" })).expect("links.backlinks");
assert_eq!(backlinks.get("count"), Some(&json!(1)));
let unresolved = reg.run("links.unresolved", &mut session, &Value::Null).expect("links.unresolved");
assert_eq!(unresolved.get("count"), Some(&json!(1)));
assert_eq!(unresolved.get("links").and_then(|links| links.get(0)).and_then(|link| link.get("source")), Some(&json!("free.md")));
let tags = reg.run("tags.list", &mut session, &Value::Null).expect("tags.list");
let root_tag = tags.get("tags").and_then(|tags| tags.get(0));
assert_eq!(root_tag.and_then(|tag| tag.get("tag")), Some(&json!("work")));
assert_eq!(root_tag.and_then(|tag| tag.get("count")), Some(&json!(1)));
let prefixed = reg.run("tags.list", &mut session, &json!({ "prefix": "nope" })).expect("tags.list prefix");
assert_eq!(prefixed.get("tags"), Some(&json!([])));
}
/// The write commands go through the vault and the index.
#[test]
fn write_commands_change_the_vault() {
let (reg, mut session, vault) = fixture("write");
reg.run("note.create", &mut session, &json!({ "path": "sub/new.md", "content": "# New\n" })).expect("note.create");
assert_eq!(on_disk(vault.path(), "sub/new.md"), "# New\n");
assert_eq!(code(&reg.run("note.create", &mut session, &json!({ "path": "sub/new.md" }))), "exists");
assert_eq!(code(&reg.run("note.create", &mut session, &json!({ "path": "sub/new.txt" }))), "invalid_path");
let written = reg.run("note.write", &mut session, &json!({ "path": "sub/new.md", "content": "# New\n\n[[sub/new]]\n" })).expect("note.write");
assert_eq!(written.get("changed"), Some(&json!(true)));
assert_eq!(session.index().note("sub/new.md").map(|note| note.links.len()), Some(1));
let renamed = reg.run("note.rename", &mut session, &json!({ "from": "sub/new.md", "to": "sub/renamed.md" })).expect("note.rename");
assert_eq!(renamed.get("rewritten"), Some(&json!(["sub/renamed.md"])), "its own self-link moved with it");
assert_eq!(on_disk(vault.path(), "sub/renamed.md"), "# New\n\n[[sub/renamed]]\n");
let no_links = reg.run("note.rename", &mut session, &json!({ "from": "free.md", "to": "free2.md", "update_links": false })).expect("note.rename");
assert_eq!(no_links.get("rewritten"), Some(&json!([])));
let deleted = reg.run("note.delete", &mut session, &json!({ "path": "free2.md" })).expect("note.delete");
assert_eq!(deleted.get("deleted"), Some(&json!(true)));
assert!(session.index().note("free2.md").is_none());
assert_eq!(code(&reg.run("note.delete", &mut session, &json!({ "path": "free2.md" }))), "not_found");
}
/// The buffer commands edit, save and undo through the vault.
#[test]
fn buffer_commands_edit_the_vault() {
let (reg, mut session, vault) = fixture("buffers");
let opened = reg.run("buffer.open", &mut session, &json!({ "path": "root.md" })).expect("buffer.open");
assert_eq!(opened.get("dirty"), Some(&json!(false)));
let edited = reg.run("buffer.edit", &mut session, &json!({ "path": "root.md", "start": 0, "end": 6, "text": "Start" })).expect("buffer.edit");
assert_eq!(edited.get("dirty"), Some(&json!(true)));
assert_eq!(on_disk(vault.path(), "root.md"), "# Root\n\n[[notes/Child]] #work\n", "not saved yet");
let saved = reg.run("buffer.save", &mut session, &json!({ "path": "root.md" })).expect("buffer.save");
assert_eq!(saved.get("changed"), Some(&json!(true)));
assert_eq!(on_disk(vault.path(), "root.md"), "Start\n\n[[notes/Child]] #work\n");
let undone = reg.run("buffer.undo", &mut session, &json!({ "path": "root.md" })).expect("buffer.undo");
assert_eq!(undone.get("undone"), Some(&json!(true)));
assert_eq!(undone.get("dirty"), Some(&json!(true)));
let redone = reg.run("buffer.redo", &mut session, &json!({ "path": "root.md" })).expect("buffer.redo");
assert_eq!(redone.get("redone"), Some(&json!(true)));
assert_eq!(reg.run("buffer.save", &mut session, &json!({ "path": "root.md" })).expect("save").get("changed"), Some(&json!(false)));
assert_eq!(code(&reg.run("buffer.edit", &mut session, &json!({ "path": "root.md", "start": 0, "end": 9999, "text": "x" }))), "invalid_path");
assert_eq!(on_disk(vault.path(), "root.md"), "Start\n\n[[notes/Child]] #work\n", "the refused edit changed nothing");
}
/// A read-only session disables the write commands and refuses their bodies.
#[test]
fn a_read_only_session_disables_every_write_command() {
let vault = temp_vault("read-only-commands");
seed(vault.path(), &[("note.md", "body\n")]);
let mut reg = Registry::new();
register_core(&mut reg).expect("register");
let mut session = Session::open_read_only(vault.path()).expect("open read-only");
for id in ["note.write", "note.create", "note.delete", "note.rename", "buffer.edit", "buffer.save"] {
assert!(!reg.is_enabled(id, &session), "{id} must be disabled");
assert!(matches!(reg.run(id, &mut session, &json!({ "path": "note.md" })), Err(Error::Disabled(_))), "{id} must not run");
}
for id in [
"vault.info",
"note.list",
"note.read",
"links.outgoing",
"links.backlinks",
"links.unresolved",
"tags.list",
"buffer.open",
"settings.get",
"settings.set",
"settings.reset",
] {
assert!(reg.is_enabled(id, &session), "{id} must stay enabled");
}
assert!(reg.run("note.read", &mut session, &json!({ "path": "note.md" })).is_ok());
assert!(reg.run("buffer.open", &mut session, &json!({ "path": "note.md" })).is_ok());
}
/// The vault refuses the reserved folders and the command surfaces that error.
#[test]
fn reserved_paths_are_refused_by_the_command() {
let (reg, mut session, vault) = fixture("reserved");
seed(vault.path(), &[(".obsidian/app.json", "{}")]);
assert!(vault.path().join(".obsidian/app.json").exists());
for id in [".obsidian/x.md", ".notescraft/y.md"] {
assert_eq!(code(&reg.run("note.write", &mut session, &json!({ "path": id, "content": "x" }))), "reserved", "note.write {id}");
assert_eq!(code(&reg.run("note.create", &mut session, &json!({ "path": id }))), "reserved", "note.create {id}");
assert_eq!(code(&reg.run("note.delete", &mut session, &json!({ "path": id }))), "reserved", "note.delete {id}");
assert_eq!(code(&reg.run("note.rename", &mut session, &json!({ "from": "root.md", "to": id }))), "reserved", "note.rename into {id}");
assert_eq!(code(&reg.run("note.rename", &mut session, &json!({ "from": id, "to": "b.md" }))), "not_found", "a reserved path is never a note");
assert!(!vault.path().join(id).exists(), "{id} must not exist");
}
// A reserved path that is not a note is refused on the extension first.
assert_eq!(code(&reg.run("note.write", &mut session, &json!({ "path": ".obsidian/app.json", "content": "x" }))), "invalid_path");
assert_eq!(on_disk(vault.path(), ".obsidian/app.json"), "{}", "Obsidian's folder is untouched");
}
/// Values the registry cannot type-check are refused before the body runs.
#[test]
fn bad_params_are_refused() {
let (reg, mut session, _vault) = fixture("bad-params");
for params in [
json!(null),
json!({}),
json!({ "path": 3 }),
json!({ "path": "note.md", "typo": 1 }),
json!({ "path": "../escape.md" }),
json!({ "path": "" }),
json!({ "path": "/abs/note.md" }),
] {
assert!(reg.run("note.write", &mut session, &params).is_err(), "note.write must refuse {params}");
}
assert!(matches!(reg.run("note.read", &mut session, &json!({ "path": "nope.md" })), Err(Error::Command { .. })));
assert!(matches!(
reg.run("note.rename", &mut session, &json!({ "from": "root.md", "to": "root.md", "update_links": 3 })),
Err(Error::InvalidParams { .. })
));
assert_eq!(code(&reg.run("buffer.edit", &mut session, &json!({ "path": "root.md", "start": -1, "end": 2, "text": "x" }))), "invalid_params");
assert!(matches!(reg.run("buffer.edit", &mut session, &json!({ "path": "root.md", "start": 0, "end": 4, "text": 7 })), Err(Error::InvalidParams { .. })));
assert_eq!(code(&reg.run("buffer.undo", &mut session, &json!({ "path": "no-buffer.md" }))), "not_found");
assert!(matches!(reg.run("no.such.command", &mut session, &Value::Null), Err(Error::UnknownCommand(_))));
}
/// No adversarial params object can panic a core command or leave the vault broken.
#[test]
fn adversarial_params_never_panic_a_core_command() {
let vault = temp_vault("fuzz");
seed(
vault.path(),
&[
("root.md", "# Root\n\n[[notes/Child]] #work\n"),
("notes/Child.md", "# Child\n\n[back](../root.md#Root) ![[img.png]]\n"),
("img.png", "not really a png"),
("free.md", "[[nowhere]]\n"),
],
);
let config = temp_config("fuzz");
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let mut session = Session::open(vault.path()).expect("open the vault");
session.use_settings_dir(config.path());
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut accepted = 0_usize;
let mut panicked = Vec::new();
for id in CORE_IDS {
for params in adversarial_params() {
match reg.run(id, &mut session, &params) {
Ok(_) => accepted += 1,
Err(Error::Panicked(other)) => panicked.push(other),
Err(_) => {}
}
}
}
std::panic::set_hook(previous);
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
assert!(accepted > 0, "at least the param-less commands accept some fixtures");
assert!(reg.run("vault.info", &mut session, &Value::Null).is_ok(), "the registry is still usable");
assert_eq!(reg.len(), CORE_IDS.len());
}
/// The command error payload is the documented `{ code, message }` shape.
#[test]
fn command_errors_carry_a_code_and_a_message() {
let (reg, mut session, _vault) = fixture("errors");
let Err(Error::Command { source, .. }) = reg.run("note.read", &mut session, &json!({ "path": "nope.md" })) else {
panic!("expected a command error");
};
assert_eq!(source.code, "not_found");
assert!(source.message.contains("nope.md"), "{}", source.message);
assert_eq!(source, CommandError::new(source.code.clone(), source.message.clone()));
}