extension_store_test.rs

  1use crate::{
  2    Event, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry,
  3    ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry,
  4    RELOAD_DEBOUNCE_DURATION,
  5};
  6use async_compression::futures::bufread::GzipEncoder;
  7use collections::BTreeMap;
  8use fs::{FakeFs, Fs, RealFs};
  9use futures::{io::BufReader, AsyncReadExt, StreamExt};
 10use gpui::{Context, TestAppContext};
 11use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
 12use node_runtime::FakeNodeRuntime;
 13use parking_lot::Mutex;
 14use project::Project;
 15use serde_json::json;
 16use settings::SettingsStore;
 17use std::{
 18    ffi::OsString,
 19    path::{Path, PathBuf},
 20    sync::Arc,
 21};
 22use theme::ThemeRegistry;
 23use util::{
 24    http::{FakeHttpClient, Response},
 25    test::temp_tree,
 26};
 27
 28#[cfg(test)]
 29#[ctor::ctor]
 30fn init_logger() {
 31    if std::env::var("RUST_LOG").is_ok() {
 32        env_logger::init();
 33    }
 34}
 35
 36#[gpui::test]
 37async fn test_extension_store(cx: &mut TestAppContext) {
 38    cx.update(|cx| {
 39        let store = SettingsStore::test(cx);
 40        cx.set_global(store);
 41        theme::init(theme::LoadThemes::JustBase, cx);
 42    });
 43
 44    let fs = FakeFs::new(cx.executor());
 45    let http_client = FakeHttpClient::with_200_response();
 46
 47    fs.insert_tree(
 48        "/the-extension-dir",
 49        json!({
 50            "installed": {
 51                "zed-monokai": {
 52                    "extension.json": r#"{
 53                        "id": "zed-monokai",
 54                        "name": "Zed Monokai",
 55                        "version": "2.0.0",
 56                        "themes": {
 57                            "Monokai Dark": "themes/monokai.json",
 58                            "Monokai Light": "themes/monokai.json",
 59                            "Monokai Pro Dark": "themes/monokai-pro.json",
 60                            "Monokai Pro Light": "themes/monokai-pro.json"
 61                        }
 62                    }"#,
 63                    "themes": {
 64                        "monokai.json": r#"{
 65                            "name": "Monokai",
 66                            "author": "Someone",
 67                            "themes": [
 68                                {
 69                                    "name": "Monokai Dark",
 70                                    "appearance": "dark",
 71                                    "style": {}
 72                                },
 73                                {
 74                                    "name": "Monokai Light",
 75                                    "appearance": "light",
 76                                    "style": {}
 77                                }
 78                            ]
 79                        }"#,
 80                        "monokai-pro.json": r#"{
 81                            "name": "Monokai Pro",
 82                            "author": "Someone",
 83                            "themes": [
 84                                {
 85                                    "name": "Monokai Pro Dark",
 86                                    "appearance": "dark",
 87                                    "style": {}
 88                                },
 89                                {
 90                                    "name": "Monokai Pro Light",
 91                                    "appearance": "light",
 92                                    "style": {}
 93                                }
 94                            ]
 95                        }"#,
 96                    }
 97                },
 98                "zed-ruby": {
 99                    "extension.json": r#"{
100                        "id": "zed-ruby",
101                        "name": "Zed Ruby",
102                        "version": "1.0.0",
103                        "grammars": {
104                            "ruby": "grammars/ruby.wasm",
105                            "embedded_template": "grammars/embedded_template.wasm"
106                        },
107                        "languages": {
108                            "ruby": "languages/ruby",
109                            "erb": "languages/erb"
110                        }
111                    }"#,
112                    "grammars": {
113                        "ruby.wasm": "",
114                        "embedded_template.wasm": "",
115                    },
116                    "languages": {
117                        "ruby": {
118                            "config.toml": r#"
119                                name = "Ruby"
120                                grammar = "ruby"
121                                path_suffixes = ["rb"]
122                            "#,
123                            "highlights.scm": "",
124                        },
125                        "erb": {
126                            "config.toml": r#"
127                                name = "ERB"
128                                grammar = "embedded_template"
129                                path_suffixes = ["erb"]
130                            "#,
131                            "highlights.scm": "",
132                        }
133                    },
134                }
135            }
136        }),
137    )
138    .await;
139
140    let mut expected_index = ExtensionIndex {
141        extensions: [
142            (
143                "zed-ruby".into(),
144                ExtensionIndexEntry {
145                    manifest: Arc::new(ExtensionManifest {
146                        id: "zed-ruby".into(),
147                        name: "Zed Ruby".into(),
148                        version: "1.0.0".into(),
149                        schema_version: 0,
150                        description: None,
151                        authors: Vec::new(),
152                        repository: None,
153                        themes: Default::default(),
154                        lib: Default::default(),
155                        languages: vec!["languages/erb".into(), "languages/ruby".into()],
156                        grammars: [
157                            ("embedded_template".into(), GrammarManifestEntry::default()),
158                            ("ruby".into(), GrammarManifestEntry::default()),
159                        ]
160                        .into_iter()
161                        .collect(),
162                        language_servers: BTreeMap::default(),
163                    }),
164                    dev: false,
165                },
166            ),
167            (
168                "zed-monokai".into(),
169                ExtensionIndexEntry {
170                    manifest: Arc::new(ExtensionManifest {
171                        id: "zed-monokai".into(),
172                        name: "Zed Monokai".into(),
173                        version: "2.0.0".into(),
174                        schema_version: 0,
175                        description: None,
176                        authors: vec![],
177                        repository: None,
178                        themes: vec![
179                            "themes/monokai-pro.json".into(),
180                            "themes/monokai.json".into(),
181                        ],
182                        lib: Default::default(),
183                        languages: Default::default(),
184                        grammars: BTreeMap::default(),
185                        language_servers: BTreeMap::default(),
186                    }),
187                    dev: false,
188                },
189            ),
190        ]
191        .into_iter()
192        .collect(),
193        languages: [
194            (
195                "ERB".into(),
196                ExtensionIndexLanguageEntry {
197                    extension: "zed-ruby".into(),
198                    path: "languages/erb".into(),
199                    grammar: Some("embedded_template".into()),
200                    matcher: LanguageMatcher {
201                        path_suffixes: vec!["erb".into()],
202                        first_line_pattern: None,
203                    },
204                },
205            ),
206            (
207                "Ruby".into(),
208                ExtensionIndexLanguageEntry {
209                    extension: "zed-ruby".into(),
210                    path: "languages/ruby".into(),
211                    grammar: Some("ruby".into()),
212                    matcher: LanguageMatcher {
213                        path_suffixes: vec!["rb".into()],
214                        first_line_pattern: None,
215                    },
216                },
217            ),
218        ]
219        .into_iter()
220        .collect(),
221        themes: [
222            (
223                "Monokai Dark".into(),
224                ExtensionIndexThemeEntry {
225                    extension: "zed-monokai".into(),
226                    path: "themes/monokai.json".into(),
227                },
228            ),
229            (
230                "Monokai Light".into(),
231                ExtensionIndexThemeEntry {
232                    extension: "zed-monokai".into(),
233                    path: "themes/monokai.json".into(),
234                },
235            ),
236            (
237                "Monokai Pro Dark".into(),
238                ExtensionIndexThemeEntry {
239                    extension: "zed-monokai".into(),
240                    path: "themes/monokai-pro.json".into(),
241                },
242            ),
243            (
244                "Monokai Pro Light".into(),
245                ExtensionIndexThemeEntry {
246                    extension: "zed-monokai".into(),
247                    path: "themes/monokai-pro.json".into(),
248                },
249            ),
250        ]
251        .into_iter()
252        .collect(),
253    };
254
255    let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
256    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
257    let node_runtime = FakeNodeRuntime::new();
258
259    let store = cx.new_model(|cx| {
260        ExtensionStore::new(
261            PathBuf::from("/the-extension-dir"),
262            None,
263            fs.clone(),
264            http_client.clone(),
265            node_runtime.clone(),
266            language_registry.clone(),
267            theme_registry.clone(),
268            cx,
269        )
270    });
271
272    cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
273    store.read_with(cx, |store, _| {
274        let index = &store.extension_index;
275        assert_eq!(index.extensions, expected_index.extensions);
276        assert_eq!(index.languages, expected_index.languages);
277        assert_eq!(index.themes, expected_index.themes);
278
279        assert_eq!(
280            language_registry.language_names(),
281            ["ERB", "Plain Text", "Ruby"]
282        );
283        assert_eq!(
284            theme_registry.list_names(false),
285            [
286                "Monokai Dark",
287                "Monokai Light",
288                "Monokai Pro Dark",
289                "Monokai Pro Light",
290                "One Dark",
291            ]
292        );
293    });
294
295    fs.insert_tree(
296        "/the-extension-dir/installed/zed-gruvbox",
297        json!({
298            "extension.json": r#"{
299                "id": "zed-gruvbox",
300                "name": "Zed Gruvbox",
301                "version": "1.0.0",
302                "themes": {
303                    "Gruvbox": "themes/gruvbox.json"
304                }
305            }"#,
306            "themes": {
307                "gruvbox.json": r#"{
308                    "name": "Gruvbox",
309                    "author": "Someone Else",
310                    "themes": [
311                        {
312                            "name": "Gruvbox",
313                            "appearance": "dark",
314                            "style": {}
315                        }
316                    ]
317                }"#,
318            }
319        }),
320    )
321    .await;
322
323    expected_index.extensions.insert(
324        "zed-gruvbox".into(),
325        ExtensionIndexEntry {
326            manifest: Arc::new(ExtensionManifest {
327                id: "zed-gruvbox".into(),
328                name: "Zed Gruvbox".into(),
329                version: "1.0.0".into(),
330                schema_version: 0,
331                description: None,
332                authors: vec![],
333                repository: None,
334                themes: vec!["themes/gruvbox.json".into()],
335                lib: Default::default(),
336                languages: Default::default(),
337                grammars: BTreeMap::default(),
338                language_servers: BTreeMap::default(),
339            }),
340            dev: false,
341        },
342    );
343    expected_index.themes.insert(
344        "Gruvbox".into(),
345        ExtensionIndexThemeEntry {
346            extension: "zed-gruvbox".into(),
347            path: "themes/gruvbox.json".into(),
348        },
349    );
350
351    let _ = store.update(cx, |store, cx| store.reload(None, cx));
352
353    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
354    store.read_with(cx, |store, _| {
355        let index = &store.extension_index;
356        assert_eq!(index.extensions, expected_index.extensions);
357        assert_eq!(index.languages, expected_index.languages);
358        assert_eq!(index.themes, expected_index.themes);
359
360        assert_eq!(
361            theme_registry.list_names(false),
362            [
363                "Gruvbox",
364                "Monokai Dark",
365                "Monokai Light",
366                "Monokai Pro Dark",
367                "Monokai Pro Light",
368                "One Dark",
369            ]
370        );
371    });
372
373    let prev_fs_metadata_call_count = fs.metadata_call_count();
374    let prev_fs_read_dir_call_count = fs.read_dir_call_count();
375
376    // Create new extension store, as if Zed were restarting.
377    drop(store);
378    let store = cx.new_model(|cx| {
379        ExtensionStore::new(
380            PathBuf::from("/the-extension-dir"),
381            None,
382            fs.clone(),
383            http_client.clone(),
384            node_runtime.clone(),
385            language_registry.clone(),
386            theme_registry.clone(),
387            cx,
388        )
389    });
390
391    cx.executor().run_until_parked();
392    store.read_with(cx, |store, _| {
393        assert_eq!(store.extension_index, expected_index);
394        assert_eq!(
395            language_registry.language_names(),
396            ["ERB", "Plain Text", "Ruby"]
397        );
398        assert_eq!(
399            language_registry.grammar_names(),
400            ["embedded_template".into(), "ruby".into()]
401        );
402        assert_eq!(
403            theme_registry.list_names(false),
404            [
405                "Gruvbox",
406                "Monokai Dark",
407                "Monokai Light",
408                "Monokai Pro Dark",
409                "Monokai Pro Light",
410                "One Dark",
411            ]
412        );
413
414        // The on-disk manifest limits the number of FS calls that need to be made
415        // on startup.
416        assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
417        assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
418    });
419
420    store.update(cx, |store, cx| {
421        store.uninstall_extension("zed-ruby".into(), cx)
422    });
423
424    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
425    expected_index.extensions.remove("zed-ruby");
426    expected_index.languages.remove("Ruby");
427    expected_index.languages.remove("ERB");
428
429    store.read_with(cx, |store, _| {
430        assert_eq!(store.extension_index, expected_index);
431        assert_eq!(language_registry.language_names(), ["Plain Text"]);
432        assert_eq!(language_registry.grammar_names(), []);
433    });
434}
435
436#[gpui::test]
437async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
438    init_test(cx);
439    cx.executor().allow_parking();
440
441    let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
442        .parent()
443        .unwrap()
444        .parent()
445        .unwrap();
446    let cache_dir = root_dir.join("target");
447    let gleam_extension_dir = root_dir.join("extensions").join("gleam");
448
449    let fs = Arc::new(RealFs);
450    let extensions_dir = temp_tree(json!({
451        "installed": {},
452        "work": {}
453    }));
454    let project_dir = temp_tree(json!({
455        "test.gleam": ""
456    }));
457
458    let extensions_dir = extensions_dir.path().canonicalize().unwrap();
459    let project_dir = project_dir.path().canonicalize().unwrap();
460
461    let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
462
463    let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
464    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
465    let node_runtime = FakeNodeRuntime::new();
466
467    let mut status_updates = language_registry.language_server_binary_statuses();
468
469    struct FakeLanguageServerVersion {
470        version: String,
471        binary_contents: String,
472        http_request_count: usize,
473    }
474
475    let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
476        version: "v1.2.3".into(),
477        binary_contents: "the-binary-contents".into(),
478        http_request_count: 0,
479    }));
480
481    let http_client = FakeHttpClient::create({
482        let language_server_version = language_server_version.clone();
483        move |request| {
484            let language_server_version = language_server_version.clone();
485            async move {
486                language_server_version.lock().http_request_count += 1;
487                let version = language_server_version.lock().version.clone();
488                let binary_contents = language_server_version.lock().binary_contents.clone();
489
490                let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
491                let asset_download_uri =
492                    format!("https://fake-download.example.com/gleam-{version}");
493
494                let uri = request.uri().to_string();
495                if uri == github_releases_uri {
496                    Ok(Response::new(
497                        json!([
498                            {
499                                "tag_name": version,
500                                "prerelease": false,
501                                "tarball_url": "",
502                                "zipball_url": "",
503                                "assets": [
504                                    {
505                                        "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
506                                        "browser_download_url": asset_download_uri
507                                    }
508                                ]
509                            }
510                        ])
511                        .to_string()
512                        .into(),
513                    ))
514                } else if uri == asset_download_uri {
515                    let mut bytes = Vec::<u8>::new();
516                    let mut archive = async_tar::Builder::new(&mut bytes);
517                    let mut header = async_tar::Header::new_gnu();
518                    header.set_size(binary_contents.len() as u64);
519                    archive
520                        .append_data(&mut header, "gleam", binary_contents.as_bytes())
521                        .await
522                        .unwrap();
523                    archive.into_inner().await.unwrap();
524                    let mut gzipped_bytes = Vec::new();
525                    let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
526                    encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
527                    Ok(Response::new(gzipped_bytes.into()))
528                } else {
529                    Ok(Response::builder().status(404).body("not found".into())?)
530                }
531            }
532        }
533    });
534
535    let extension_store = cx.new_model(|cx| {
536        ExtensionStore::new(
537            extensions_dir.clone(),
538            Some(cache_dir),
539            fs.clone(),
540            http_client.clone(),
541            node_runtime,
542            language_registry.clone(),
543            theme_registry.clone(),
544            cx,
545        )
546    });
547
548    // Ensure that debounces fire.
549    let mut events = cx.events(&extension_store);
550    let executor = cx.executor();
551    let _task = cx.executor().spawn(async move {
552        while let Some(event) = events.next().await {
553            match event {
554                crate::Event::StartedReloading => {
555                    executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
556                }
557                _ => (),
558            }
559        }
560    });
561
562    extension_store.update(cx, |_, cx| {
563        cx.subscribe(&extension_store, |_, _, event, _| {
564            if matches!(event, Event::ExtensionFailedToLoad(_)) {
565                panic!("extension failed to load");
566            }
567        })
568        .detach();
569    });
570
571    extension_store
572        .update(cx, |store, cx| {
573            store.install_dev_extension(gleam_extension_dir.clone(), cx)
574        })
575        .await
576        .unwrap();
577
578    let mut fake_servers = language_registry.fake_language_servers("Gleam");
579
580    let buffer = project
581        .update(cx, |project, cx| {
582            project.open_local_buffer(project_dir.join("test.gleam"), cx)
583        })
584        .await
585        .unwrap();
586
587    let fake_server = fake_servers.next().await.unwrap();
588    let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
589    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
590
591    assert_eq!(fake_server.binary.path, expected_server_path);
592    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
593    assert_eq!(
594        fs.load(&expected_server_path).await.unwrap(),
595        expected_binary_contents
596    );
597    assert_eq!(language_server_version.lock().http_request_count, 2);
598    assert_eq!(
599        [
600            status_updates.next().await.unwrap(),
601            status_updates.next().await.unwrap(),
602            status_updates.next().await.unwrap(),
603        ],
604        [
605            (
606                LanguageServerName("gleam".into()),
607                LanguageServerBinaryStatus::CheckingForUpdate
608            ),
609            (
610                LanguageServerName("gleam".into()),
611                LanguageServerBinaryStatus::Downloading
612            ),
613            (
614                LanguageServerName("gleam".into()),
615                LanguageServerBinaryStatus::None
616            )
617        ]
618    );
619
620    // Simulate a new version of the language server being released
621    language_server_version.lock().version = "v2.0.0".into();
622    language_server_version.lock().binary_contents = "the-new-binary-contents".into();
623    language_server_version.lock().http_request_count = 0;
624
625    // Start a new instance of the language server.
626    project.update(cx, |project, cx| {
627        project.restart_language_servers_for_buffers([buffer.clone()], cx)
628    });
629
630    // The extension has cached the binary path, and does not attempt
631    // to reinstall it.
632    let fake_server = fake_servers.next().await.unwrap();
633    assert_eq!(fake_server.binary.path, expected_server_path);
634    assert_eq!(
635        fs.load(&expected_server_path).await.unwrap(),
636        expected_binary_contents
637    );
638    assert_eq!(language_server_version.lock().http_request_count, 0);
639
640    // Reload the extension, clearing its cache.
641    // Start a new instance of the language server.
642    extension_store
643        .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
644        .await;
645
646    cx.executor().run_until_parked();
647    project.update(cx, |project, cx| {
648        project.restart_language_servers_for_buffers([buffer.clone()], cx)
649    });
650
651    // The extension re-fetches the latest version of the language server.
652    let fake_server = fake_servers.next().await.unwrap();
653    let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
654    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
655    assert_eq!(fake_server.binary.path, new_expected_server_path);
656    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
657    assert_eq!(
658        fs.load(&new_expected_server_path).await.unwrap(),
659        expected_binary_contents
660    );
661
662    // The old language server directory has been cleaned up.
663    assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
664}
665
666fn init_test(cx: &mut TestAppContext) {
667    cx.update(|cx| {
668        let store = SettingsStore::test(cx);
669        cx.set_global(store);
670        theme::init(theme::LoadThemes::JustBase, cx);
671        Project::init_settings(cx);
672        language::init(cx);
673    });
674}