//! 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", "plugins.approve", "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", "assistant.checkpoint_turn", "assistant.checkpoint_list", "assistant.checkpoint_restore", "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, 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) -> 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(®.run("note.create", &mut session, &json!({ "path": "sub/new.md" }))), "exists"); assert_eq!(code(®.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(®.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(®.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(®.run("note.write", &mut session, &json!({ "path": id, "content": "x" }))), "reserved", "note.write {id}"); assert_eq!(code(®.run("note.create", &mut session, &json!({ "path": id }))), "reserved", "note.create {id}"); assert_eq!(code(®.run("note.delete", &mut session, &json!({ "path": id }))), "reserved", "note.delete {id}"); assert_eq!(code(®.run("note.rename", &mut session, &json!({ "from": "root.md", "to": id }))), "reserved", "note.rename into {id}"); assert_eq!(code(®.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(®.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, ¶ms).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(®.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(®.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, ¶ms) { 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())); }