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