crates/cli/ents-lens/tests/lens.rs
lens.rshistorycomment on this file
| 1 | //! Integration coverage for `docs/spec/lens.adoc`, driving the [`Lens`] |
| 2 | //! request handlers directly against a fixture repository — the strategy |
| 3 | //! the engineering conventions select for a protocol surface: construct the |
| 4 | //! server in-process with a real working tree and a comment anchored into |
| 5 | //! it, then assert each handler's derived LSP value, rather than spawning a |
| 6 | //! stdio process and parsing frames. The JSON-RPC framing is `lsp-server`'s |
| 7 | //! own tested concern; what this crate owns is the derivation, so that is |
| 8 | //! what these tests exercise. |
| 9 | //! |
| 10 | //! The seams are `ents-testutil`'s in-memory `MemRefStore`/`ObjectStore` |
| 11 | //! (the same pair every library crate's tests use) paired with a real |
| 12 | //! on-disk repository for the working tree the anchors project onto — |
| 13 | //! `ents_forge::comment::add` embeds the anchored bytes into the object |
| 14 | //! store, so the two stay consistent even though only one is on disk. |
| 15 | |
| 16 | #![allow( |
| 17 | clippy::expect_used, |
| 18 | clippy::unwrap_used, |
| 19 | clippy::indexing_slicing, |
| 20 | clippy::panic, |
| 21 | reason = "integration test" |
| 22 | )] |
| 23 | |
| 24 | use std::path::Path; |
| 25 | use std::process::Command; |
| 26 | |
| 27 | use ents_forge::comment::{self, NewComment}; |
| 28 | use ents_lens::{CMD_COMPOSE, CMD_RESOLVE, CMD_VIEW, Lens, Signing}; |
| 29 | use ents_receive::{Identity, Mode, NullEventSink}; |
| 30 | use ents_testutil::{Keypair, MemRefStore, ObjectStore}; |
| 31 | use lsp_types::{DiagnosticSeverity, HoverContents, Position, Range, Url}; |
| 32 | use serde_json::json; |
| 33 | |
| 34 | /// A fixture repository, its in-memory seams, and a deterministic signing |
| 35 | /// key — everything a [`Lens`] needs to be wired the way `git ents lsp` |
| 36 | /// wires it. |
| 37 | struct Fixture { |
| 38 | dir: tempfile::TempDir, |
| 39 | refs: MemRefStore, |
| 40 | objects: ObjectStore, |
| 41 | key: Keypair, |
| 42 | } |
| 43 | |
| 44 | impl Fixture { |
| 45 | /// A repository holding `file.txt` with ten numbered lines, committed. |
| 46 | fn new() -> Self { |
| 47 | let dir = tempfile::tempdir().expect("tempdir"); |
| 48 | gix::init(dir.path()).expect("init"); |
| 49 | let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect(); |
| 50 | commit_file(dir.path(), "file.txt", &contents); |
| 51 | Self { |
| 52 | dir, |
| 53 | refs: MemRefStore::default(), |
| 54 | objects: ObjectStore::default(), |
| 55 | key: Keypair::from_seed(1), |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | fn uri(&self, rel: &str) -> Url { |
| 60 | Url::from_file_path(self.dir.path().join(rel)).expect("file uri") |
| 61 | } |
| 62 | |
| 63 | fn actor(&self) -> gix::actor::Signature { |
| 64 | gix::actor::Signature { |
| 65 | name: "jdc".into(), |
| 66 | email: "jdc@ents.test".into(), |
| 67 | time: gix::date::Time { |
| 68 | seconds: 1_000, |
| 69 | offset: 0, |
| 70 | }, |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /// Add a comment through the same library call the CLI makes |
| 75 | /// (`lens.parity`), anchored to `lines` of `file.txt` against the |
| 76 | /// working tree. |
| 77 | fn add_comment(&self, body: &str, lines: Option<&str>) -> String { |
| 78 | let new = NewComment { |
| 79 | body: body.to_owned(), |
| 80 | path: Some("file.txt".to_owned()), |
| 81 | lines: lines.map(str::to_owned), |
| 82 | rev: "HEAD".to_owned(), |
| 83 | worktree: true, |
| 84 | context: None, |
| 85 | parent: None, |
| 86 | }; |
| 87 | let key = &self.key; |
| 88 | let sign = |payload: &[u8]| key.sign(payload); |
| 89 | let identity = Identity { |
| 90 | actor: self.actor(), |
| 91 | author: None, |
| 92 | sign: &sign, |
| 93 | }; |
| 94 | let (id, _outcome) = comment::add( |
| 95 | &self.refs, |
| 96 | &self.objects, |
| 97 | &NullEventSink, |
| 98 | self.dir.path(), |
| 99 | new, |
| 100 | &identity, |
| 101 | Mode::Advisory, |
| 102 | ) |
| 103 | .expect("adds a comment"); |
| 104 | id |
| 105 | } |
| 106 | |
| 107 | /// Consume the fixture into a wired [`Lens`] (the seams move in, exactly |
| 108 | /// as `git ents lsp`'s composition root moves `LocalRoot`'s seams in). |
| 109 | fn into_lens(self) -> (Lens<ObjectStore>, tempfile::TempDir) { |
| 110 | let key = Keypair::from_seed(1); |
| 111 | let signing = Signing::new( |
| 112 | self.actor(), |
| 113 | Box::new(move |payload| key.sign(payload)), |
| 114 | self.key.public_openssh(), |
| 115 | ); |
| 116 | let lens = Lens::new( |
| 117 | Box::new(self.refs), |
| 118 | self.objects, |
| 119 | Box::new(NullEventSink), |
| 120 | Mode::Advisory, |
| 121 | signing, |
| 122 | self.dir.path().to_owned(), |
| 123 | ); |
| 124 | (lens, self.dir) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | fn commit_file(dir: &Path, path: &str, contents: &str) { |
| 129 | std::fs::write(dir.join(path), contents).expect("write"); |
| 130 | run_git(dir, &["add", "-A"]); |
| 131 | run_git( |
| 132 | dir, |
| 133 | &[ |
| 134 | "-c", |
| 135 | "user.name=test", |
| 136 | "-c", |
| 137 | "user.email=test@example.com", |
| 138 | "commit", |
| 139 | "-q", |
| 140 | "-m", |
| 141 | "seed", |
| 142 | ], |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | fn run_git(dir: &Path, args: &[&str]) { |
| 147 | let status = Command::new("git") |
| 148 | .arg("-C") |
| 149 | .arg(dir) |
| 150 | .args(args) |
| 151 | .status() |
| 152 | .expect("git runs"); |
| 153 | assert!(status.success(), "git {args:?} failed"); |
| 154 | } |
| 155 | |
| 156 | /// `lens.lenses`: an open comment whose anchor projects onto the document |
| 157 | /// surfaces as code lenses at its projected line, identifying the comment |
| 158 | /// and offering View/Reply/Resolve as commands. `lens.diagnostics`: the |
| 159 | /// same comment is also a hint-severity diagnostic at the same range. |
| 160 | #[test] |
| 161 | // @relation(lens.lenses, lens.diagnostics, scope=function, role=Verifies) |
| 162 | fn code_lenses_and_hint_diagnostics_surface_an_open_comment() { |
| 163 | let fixture = Fixture::new(); |
| 164 | fixture.add_comment("this looks off by one", Some("5:5")); |
| 165 | let uri = fixture.uri("file.txt"); |
| 166 | let (lens, _dir) = fixture.into_lens(); |
| 167 | |
| 168 | let lenses = lens.code_lenses(&uri).expect("code lenses"); |
| 169 | assert_eq!(lenses.len(), 3, "one View/Reply/Resolve set"); |
| 170 | // Line 5 is 0-based line 4. |
| 171 | assert_eq!(lenses[0].range.start.line, 4); |
| 172 | let commands: Vec<&str> = lenses |
| 173 | .iter() |
| 174 | .filter_map(|lens| lens.command.as_ref().map(|c| c.command.as_str())) |
| 175 | .collect(); |
| 176 | assert!(commands.contains(&CMD_VIEW)); |
| 177 | assert!(commands.contains(&"ents.reply")); |
| 178 | assert!(commands.contains(&CMD_RESOLVE)); |
| 179 | assert!( |
| 180 | lenses[0] |
| 181 | .command |
| 182 | .as_ref() |
| 183 | .unwrap() |
| 184 | .title |
| 185 | .contains("off by one") |
| 186 | ); |
| 187 | |
| 188 | let diagnostics = lens.diagnostics(&uri).expect("diagnostics"); |
| 189 | assert_eq!(diagnostics.len(), 1); |
| 190 | // `lens.diagnostics` is binding: hint severity, never a warning/error. |
| 191 | assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT)); |
| 192 | assert_eq!(diagnostics[0].range.start.line, 4); |
| 193 | } |
| 194 | |
| 195 | /// `lens.hover`: hovering the anchored range returns the whole thread — |
| 196 | /// the root comment and its reply, bodies and authorship — as markup. |
| 197 | #[test] |
| 198 | // @relation(lens.hover, scope=function, role=Verifies) |
| 199 | fn hover_returns_the_full_thread() { |
| 200 | let fixture = Fixture::new(); |
| 201 | let root = fixture.add_comment("root remark", Some("5:5")); |
| 202 | // A reply, created through the same library the lens uses. |
| 203 | let key = Keypair::from_seed(1); |
| 204 | let sign = |payload: &[u8]| key.sign(payload); |
| 205 | let identity = Identity { |
| 206 | actor: fixture.actor(), |
| 207 | author: None, |
| 208 | sign: &sign, |
| 209 | }; |
| 210 | comment::reply( |
| 211 | &fixture.refs, |
| 212 | &fixture.objects, |
| 213 | &NullEventSink, |
| 214 | &root, |
| 215 | "a reply body".to_owned(), |
| 216 | &identity, |
| 217 | Mode::Advisory, |
| 218 | ) |
| 219 | .expect("replies"); |
| 220 | let uri = fixture.uri("file.txt"); |
| 221 | let (lens, _dir) = fixture.into_lens(); |
| 222 | |
| 223 | let hover = lens |
| 224 | .hover( |
| 225 | &uri, |
| 226 | Position { |
| 227 | line: 4, |
| 228 | character: 0, |
| 229 | }, |
| 230 | ) |
| 231 | .expect("hover") |
| 232 | .expect("a comment is anchored at line 5"); |
| 233 | let HoverContents::Markup(markup) = hover.contents else { |
| 234 | panic!("hover must be markup"); |
| 235 | }; |
| 236 | assert!(markup.value.contains("root remark")); |
| 237 | assert!(markup.value.contains("a reply body")); |
| 238 | assert!( |
| 239 | markup.value.contains("jdc"), |
| 240 | "authorship from the commit chain" |
| 241 | ); |
| 242 | |
| 243 | // Hovering an unrelated line yields nothing. |
| 244 | assert!( |
| 245 | lens.hover( |
| 246 | &uri, |
| 247 | Position { |
| 248 | line: 0, |
| 249 | character: 0 |
| 250 | } |
| 251 | ) |
| 252 | .expect("hover") |
| 253 | .is_none() |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | /// `lens.compose`: a code action on a selection offers "Leave an ents |
| 258 | /// comment", whose command opens the template; running it writes the |
| 259 | /// template under `.git/` and asks the client to open that file. |
| 260 | #[test] |
| 261 | // @relation(lens.compose, scope=function, role=Verifies) |
| 262 | fn code_action_and_compose_open_the_template() { |
| 263 | let fixture = Fixture::new(); |
| 264 | let uri = fixture.uri("file.txt"); |
| 265 | let (lens, dir) = fixture.into_lens(); |
| 266 | |
| 267 | let range = Range { |
| 268 | start: Position { |
| 269 | line: 1, |
| 270 | character: 0, |
| 271 | }, |
| 272 | end: Position { |
| 273 | line: 2, |
| 274 | character: 0, |
| 275 | }, |
| 276 | }; |
| 277 | let actions = lens.code_actions(&uri, range).expect("code actions"); |
| 278 | assert_eq!(actions.len(), 1); |
| 279 | let lsp_types::CodeActionOrCommand::CodeAction(action) = &actions[0] else { |
| 280 | panic!("expected a code action"); |
| 281 | }; |
| 282 | assert_eq!(action.title, "Leave an ents comment"); |
| 283 | let command = action.command.as_ref().expect("carries a command"); |
| 284 | assert_eq!(command.command, CMD_COMPOSE); |
| 285 | |
| 286 | // Running the command writes the template and asks to open it. |
| 287 | let outcome = lens |
| 288 | .execute_command( |
| 289 | CMD_COMPOSE, |
| 290 | &[json!({ "path": "file.txt", "lines": "2:2" })], |
| 291 | ) |
| 292 | .expect("compose"); |
| 293 | let template = outcome.show_document.expect("opens the template"); |
| 294 | assert_eq!( |
| 295 | template, |
| 296 | dir.path().join(".git").join("ENTS_COMMENT_EDITMSG") |
| 297 | ); |
| 298 | let written = std::fs::read_to_string(&template).expect("template written"); |
| 299 | assert!(written.contains("ents-compose-path: file.txt")); |
| 300 | assert!(written.contains("Lines starting with '#' are ignored")); |
| 301 | } |
| 302 | |
| 303 | /// `lens.compose` end to end: saving the template with a non-empty body |
| 304 | /// creates the comment (anchored to the working tree, `lens.working-tree`), |
| 305 | /// and it then surfaces as a code lens; an empty body aborts. |
| 306 | #[test] |
| 307 | // @relation(lens.compose, lens.working-tree, lens.parity, scope=function, role=Verifies) |
| 308 | fn saving_a_nonempty_body_creates_the_comment_and_empty_aborts() { |
| 309 | let fixture = Fixture::new(); |
| 310 | let uri = fixture.uri("file.txt"); |
| 311 | let (lens, dir) = fixture.into_lens(); |
| 312 | let template = dir.path().join(".git").join("ENTS_COMMENT_EDITMSG"); |
| 313 | let template_uri = Url::from_file_path(&template).unwrap(); |
| 314 | |
| 315 | // Start a compose targeting line 3. |
| 316 | lens.execute_command( |
| 317 | CMD_COMPOSE, |
| 318 | &[json!({ "path": "file.txt", "lines": "3:3" })], |
| 319 | ) |
| 320 | .expect("compose"); |
| 321 | |
| 322 | // An empty save aborts: no comment, template removed. |
| 323 | std::fs::write( |
| 324 | &template, |
| 325 | "\n# only comments here\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n", |
| 326 | ) |
| 327 | .unwrap(); |
| 328 | lens.did_save(&template_uri).expect("save"); |
| 329 | assert!(lens.code_lenses(&uri).expect("lenses").is_empty()); |
| 330 | assert!(!template.exists(), "aborted compose removes the template"); |
| 331 | |
| 332 | // Re-start and save a real body: the comment is created and surfaces. |
| 333 | lens.execute_command( |
| 334 | CMD_COMPOSE, |
| 335 | &[json!({ "path": "file.txt", "lines": "3:3" })], |
| 336 | ) |
| 337 | .expect("compose"); |
| 338 | std::fs::write( |
| 339 | &template, |
| 340 | "the third line is wrong\n# ignored\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n", |
| 341 | ) |
| 342 | .unwrap(); |
| 343 | lens.did_save(&template_uri).expect("save"); |
| 344 | |
| 345 | let lenses = lens.code_lenses(&uri).expect("lenses"); |
| 346 | assert_eq!(lenses.len(), 3, "the composed comment now surfaces"); |
| 347 | assert_eq!(lenses[0].range.start.line, 2, "anchored at line 3"); |
| 348 | assert!( |
| 349 | lenses[0] |
| 350 | .command |
| 351 | .as_ref() |
| 352 | .unwrap() |
| 353 | .title |
| 354 | .contains("third line is wrong") |
| 355 | ); |
| 356 | } |
| 357 | |
| 358 | /// `lens.parity` + `model.comment-state`: View returns the thread, and |
| 359 | /// Resolve — the same library call the CLI runs — drops the comment from |
| 360 | /// the next publish, since only open comments surface (`lens.lenses`). |
| 361 | #[test] |
| 362 | // @relation(lens.parity, lens.lenses, scope=function, role=Verifies) |
| 363 | fn view_returns_the_thread_and_resolve_hides_it() { |
| 364 | let fixture = Fixture::new(); |
| 365 | let id = fixture.add_comment("please fix", Some("5:5")); |
| 366 | let uri = fixture.uri("file.txt"); |
| 367 | let (lens, _dir) = fixture.into_lens(); |
| 368 | |
| 369 | let view = lens |
| 370 | .execute_command(CMD_VIEW, &[json!(id)]) |
| 371 | .expect("view") |
| 372 | .response |
| 373 | .expect("view returns the thread"); |
| 374 | assert!(view.as_str().unwrap().contains("please fix")); |
| 375 | |
| 376 | // Resolve, then the open-only publish no longer shows it. |
| 377 | let outcome = lens |
| 378 | .execute_command(CMD_RESOLVE, &[json!(id)]) |
| 379 | .expect("resolve"); |
| 380 | assert!(outcome.refresh, "a mutation asks for a diagnostics refresh"); |
| 381 | assert!(lens.code_lenses(&uri).expect("lenses").is_empty()); |
| 382 | assert!(lens.diagnostics(&uri).expect("diags").is_empty()); |
| 383 | } |
| 384 | |
| 385 | /// `lens.working-tree`: the open buffer stands in for disk, so a comment's |
| 386 | /// range tracks unsaved edits — prepending two lines in the buffer shifts |
| 387 | /// the projected lens down by two. |
| 388 | #[test] |
| 389 | // @relation(lens.working-tree, scope=function, role=Verifies) |
| 390 | fn the_buffer_overrides_disk_so_ranges_track_unsaved_edits() { |
| 391 | let fixture = Fixture::new(); |
| 392 | fixture.add_comment("watch this line", Some("5:5")); |
| 393 | let uri = fixture.uri("file.txt"); |
| 394 | let (mut lens, _dir) = fixture.into_lens(); |
| 395 | |
| 396 | // On disk the anchor is line 5 (0-based 4). |
| 397 | let on_disk = lens.code_lenses(&uri).expect("lenses"); |
| 398 | assert_eq!(on_disk[0].range.start.line, 4); |
| 399 | |
| 400 | // The client sends a buffer with two extra lines prepended, unsaved. |
| 401 | let buffer: String = std::iter::once("added a".to_owned()) |
| 402 | .chain(std::iter::once("added b".to_owned())) |
| 403 | .chain((1..=10).map(|n| format!("line {n}"))) |
| 404 | .collect::<Vec<_>>() |
| 405 | .join("\n"); |
| 406 | lens.did_open(uri.clone(), format!("{buffer}\n")); |
| 407 | |
| 408 | let shifted = lens.code_lenses(&uri).expect("lenses"); |
| 409 | assert_eq!(shifted[0].range.start.line, 6, "line 5 shifted to line 7"); |
| 410 | } |