extension_store_test.rs

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