extension_store_test.rs

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