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            None,
266            node_runtime.clone(),
267            language_registry.clone(),
268            theme_registry.clone(),
269            cx,
270        )
271    });
272
273    cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
274    store.read_with(cx, |store, _| {
275        let index = &store.extension_index;
276        assert_eq!(index.extensions, expected_index.extensions);
277        assert_eq!(index.languages, expected_index.languages);
278        assert_eq!(index.themes, expected_index.themes);
279
280        assert_eq!(
281            language_registry.language_names(),
282            ["ERB", "Plain Text", "Ruby"]
283        );
284        assert_eq!(
285            theme_registry.list_names(false),
286            [
287                "Monokai Dark",
288                "Monokai Light",
289                "Monokai Pro Dark",
290                "Monokai Pro Light",
291                "One Dark",
292            ]
293        );
294    });
295
296    fs.insert_tree(
297        "/the-extension-dir/installed/zed-gruvbox",
298        json!({
299            "extension.json": r#"{
300                "id": "zed-gruvbox",
301                "name": "Zed Gruvbox",
302                "version": "1.0.0",
303                "themes": {
304                    "Gruvbox": "themes/gruvbox.json"
305                }
306            }"#,
307            "themes": {
308                "gruvbox.json": r#"{
309                    "name": "Gruvbox",
310                    "author": "Someone Else",
311                    "themes": [
312                        {
313                            "name": "Gruvbox",
314                            "appearance": "dark",
315                            "style": {}
316                        }
317                    ]
318                }"#,
319            }
320        }),
321    )
322    .await;
323
324    expected_index.extensions.insert(
325        "zed-gruvbox".into(),
326        ExtensionIndexEntry {
327            manifest: Arc::new(ExtensionManifest {
328                id: "zed-gruvbox".into(),
329                name: "Zed Gruvbox".into(),
330                version: "1.0.0".into(),
331                schema_version: 0,
332                description: None,
333                authors: vec![],
334                repository: None,
335                themes: vec!["themes/gruvbox.json".into()],
336                lib: Default::default(),
337                languages: Default::default(),
338                grammars: BTreeMap::default(),
339                language_servers: BTreeMap::default(),
340            }),
341            dev: false,
342        },
343    );
344    expected_index.themes.insert(
345        "Gruvbox".into(),
346        ExtensionIndexThemeEntry {
347            extension: "zed-gruvbox".into(),
348            path: "themes/gruvbox.json".into(),
349        },
350    );
351
352    let _ = store.update(cx, |store, cx| store.reload(None, cx));
353
354    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
355    store.read_with(cx, |store, _| {
356        let index = &store.extension_index;
357        assert_eq!(index.extensions, expected_index.extensions);
358        assert_eq!(index.languages, expected_index.languages);
359        assert_eq!(index.themes, expected_index.themes);
360
361        assert_eq!(
362            theme_registry.list_names(false),
363            [
364                "Gruvbox",
365                "Monokai Dark",
366                "Monokai Light",
367                "Monokai Pro Dark",
368                "Monokai Pro Light",
369                "One Dark",
370            ]
371        );
372    });
373
374    let prev_fs_metadata_call_count = fs.metadata_call_count();
375    let prev_fs_read_dir_call_count = fs.read_dir_call_count();
376
377    // Create new extension store, as if Zed were restarting.
378    drop(store);
379    let store = cx.new_model(|cx| {
380        ExtensionStore::new(
381            PathBuf::from("/the-extension-dir"),
382            None,
383            fs.clone(),
384            http_client.clone(),
385            None,
386            node_runtime.clone(),
387            language_registry.clone(),
388            theme_registry.clone(),
389            cx,
390        )
391    });
392
393    cx.executor().run_until_parked();
394    store.read_with(cx, |store, _| {
395        assert_eq!(store.extension_index, expected_index);
396        assert_eq!(
397            language_registry.language_names(),
398            ["ERB", "Plain Text", "Ruby"]
399        );
400        assert_eq!(
401            language_registry.grammar_names(),
402            ["embedded_template".into(), "ruby".into()]
403        );
404        assert_eq!(
405            theme_registry.list_names(false),
406            [
407                "Gruvbox",
408                "Monokai Dark",
409                "Monokai Light",
410                "Monokai Pro Dark",
411                "Monokai Pro Light",
412                "One Dark",
413            ]
414        );
415
416        // The on-disk manifest limits the number of FS calls that need to be made
417        // on startup.
418        assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
419        assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
420    });
421
422    store.update(cx, |store, cx| {
423        store.uninstall_extension("zed-ruby".into(), cx)
424    });
425
426    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
427    expected_index.extensions.remove("zed-ruby");
428    expected_index.languages.remove("Ruby");
429    expected_index.languages.remove("ERB");
430
431    store.read_with(cx, |store, _| {
432        assert_eq!(store.extension_index, expected_index);
433        assert_eq!(language_registry.language_names(), ["Plain Text"]);
434        assert_eq!(language_registry.grammar_names(), []);
435    });
436}
437
438#[gpui::test]
439async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
440    init_test(cx);
441    cx.executor().allow_parking();
442
443    let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
444        .parent()
445        .unwrap()
446        .parent()
447        .unwrap();
448    let cache_dir = root_dir.join("target");
449    let gleam_extension_dir = root_dir.join("extensions").join("gleam");
450
451    let fs = Arc::new(RealFs);
452    let extensions_dir = temp_tree(json!({
453        "installed": {},
454        "work": {}
455    }));
456    let project_dir = temp_tree(json!({
457        "test.gleam": ""
458    }));
459
460    let extensions_dir = extensions_dir.path().canonicalize().unwrap();
461    let project_dir = project_dir.path().canonicalize().unwrap();
462
463    let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
464
465    let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
466    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
467    let node_runtime = FakeNodeRuntime::new();
468
469    let mut status_updates = language_registry.language_server_binary_statuses();
470
471    struct FakeLanguageServerVersion {
472        version: String,
473        binary_contents: String,
474        http_request_count: usize,
475    }
476
477    let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
478        version: "v1.2.3".into(),
479        binary_contents: "the-binary-contents".into(),
480        http_request_count: 0,
481    }));
482
483    let http_client = FakeHttpClient::create({
484        let language_server_version = language_server_version.clone();
485        move |request| {
486            let language_server_version = language_server_version.clone();
487            async move {
488                language_server_version.lock().http_request_count += 1;
489                let version = language_server_version.lock().version.clone();
490                let binary_contents = language_server_version.lock().binary_contents.clone();
491
492                let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
493                let asset_download_uri =
494                    format!("https://fake-download.example.com/gleam-{version}");
495
496                let uri = request.uri().to_string();
497                if uri == github_releases_uri {
498                    Ok(Response::new(
499                        json!([
500                            {
501                                "tag_name": version,
502                                "prerelease": false,
503                                "tarball_url": "",
504                                "zipball_url": "",
505                                "assets": [
506                                    {
507                                        "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
508                                        "browser_download_url": asset_download_uri
509                                    }
510                                ]
511                            }
512                        ])
513                        .to_string()
514                        .into(),
515                    ))
516                } else if uri == asset_download_uri {
517                    let mut bytes = Vec::<u8>::new();
518                    let mut archive = async_tar::Builder::new(&mut bytes);
519                    let mut header = async_tar::Header::new_gnu();
520                    header.set_size(binary_contents.len() as u64);
521                    archive
522                        .append_data(&mut header, "gleam", binary_contents.as_bytes())
523                        .await
524                        .unwrap();
525                    archive.into_inner().await.unwrap();
526                    let mut gzipped_bytes = Vec::new();
527                    let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
528                    encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
529                    Ok(Response::new(gzipped_bytes.into()))
530                } else {
531                    Ok(Response::builder().status(404).body("not found".into())?)
532                }
533            }
534        }
535    });
536
537    let extension_store = cx.new_model(|cx| {
538        ExtensionStore::new(
539            extensions_dir.clone(),
540            Some(cache_dir),
541            fs.clone(),
542            http_client.clone(),
543            None,
544            node_runtime,
545            language_registry.clone(),
546            theme_registry.clone(),
547            cx,
548        )
549    });
550
551    // Ensure that debounces fire.
552    let mut events = cx.events(&extension_store);
553    let executor = cx.executor();
554    let _task = cx.executor().spawn(async move {
555        while let Some(event) = events.next().await {
556            match event {
557                crate::Event::StartedReloading => {
558                    executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
559                }
560                _ => (),
561            }
562        }
563    });
564
565    extension_store.update(cx, |_, cx| {
566        cx.subscribe(&extension_store, |_, _, event, _| {
567            if matches!(event, Event::ExtensionFailedToLoad(_)) {
568                panic!("extension failed to load");
569            }
570        })
571        .detach();
572    });
573
574    extension_store
575        .update(cx, |store, cx| {
576            store.install_dev_extension(gleam_extension_dir.clone(), cx)
577        })
578        .await
579        .unwrap();
580
581    let mut fake_servers = language_registry.fake_language_servers("Gleam");
582
583    let buffer = project
584        .update(cx, |project, cx| {
585            project.open_local_buffer(project_dir.join("test.gleam"), cx)
586        })
587        .await
588        .unwrap();
589
590    let fake_server = fake_servers.next().await.unwrap();
591    let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
592    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
593
594    assert_eq!(fake_server.binary.path, expected_server_path);
595    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
596    assert_eq!(
597        fs.load(&expected_server_path).await.unwrap(),
598        expected_binary_contents
599    );
600    assert_eq!(language_server_version.lock().http_request_count, 2);
601    assert_eq!(
602        [
603            status_updates.next().await.unwrap(),
604            status_updates.next().await.unwrap(),
605            status_updates.next().await.unwrap(),
606        ],
607        [
608            (
609                LanguageServerName("gleam".into()),
610                LanguageServerBinaryStatus::CheckingForUpdate
611            ),
612            (
613                LanguageServerName("gleam".into()),
614                LanguageServerBinaryStatus::Downloading
615            ),
616            (
617                LanguageServerName("gleam".into()),
618                LanguageServerBinaryStatus::None
619            )
620        ]
621    );
622
623    // Simulate a new version of the language server being released
624    language_server_version.lock().version = "v2.0.0".into();
625    language_server_version.lock().binary_contents = "the-new-binary-contents".into();
626    language_server_version.lock().http_request_count = 0;
627
628    // Start a new instance of the language server.
629    project.update(cx, |project, cx| {
630        project.restart_language_servers_for_buffers([buffer.clone()], cx)
631    });
632
633    // The extension has cached the binary path, and does not attempt
634    // to reinstall it.
635    let fake_server = fake_servers.next().await.unwrap();
636    assert_eq!(fake_server.binary.path, expected_server_path);
637    assert_eq!(
638        fs.load(&expected_server_path).await.unwrap(),
639        expected_binary_contents
640    );
641    assert_eq!(language_server_version.lock().http_request_count, 0);
642
643    // Reload the extension, clearing its cache.
644    // Start a new instance of the language server.
645    extension_store
646        .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
647        .await;
648
649    cx.executor().run_until_parked();
650    project.update(cx, |project, cx| {
651        project.restart_language_servers_for_buffers([buffer.clone()], cx)
652    });
653
654    // The extension re-fetches the latest version of the language server.
655    let fake_server = fake_servers.next().await.unwrap();
656    let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
657    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
658    assert_eq!(fake_server.binary.path, new_expected_server_path);
659    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
660    assert_eq!(
661        fs.load(&new_expected_server_path).await.unwrap(),
662        expected_binary_contents
663    );
664
665    // The old language server directory has been cleaned up.
666    assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
667}
668
669fn init_test(cx: &mut TestAppContext) {
670    cx.update(|cx| {
671        let store = SettingsStore::test(cx);
672        cx.set_global(store);
673        theme::init(theme::LoadThemes::JustBase, cx);
674        Project::init_settings(cx);
675        language::init(cx);
676    });
677}