char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsnapshot: embed shared main and worker startup in the CLI688e96e

main
29.9 KiB886 linesraw
1use avril_runtime::config::{ValueKind, parse};
2#[path = "common/registry.rs"]
3mod fixture;
4
5use avril_runtime::config_edit::{Addition, Change, add, add_file, unversioned, with_caret};
6use fixture::Registry;
7use std::fs;
8use std::path::Path;
9use std::process::{Command, Output};
10
11fn registry() -> Registry {
12    let server = Registry::new();
13    for version in ["1.0.0", "2.0.0"] {
14        server.package(
15            "@test/pkg",
16            version,
17            &[
18                ("mod.js", &format!("export const version = '{version}';")),
19                ("sub.js", "export const sub = true;"),
20            ],
21            &[(".", "./mod.js"), ("./sub", "./sub.js")],
22        );
23    }
24    server
25}
26
27/// Runs `avril add` against `server`, caching beneath `root`.
28fn avril_add(server: &Registry, root: &Path, cwd: &Path, args: &[&str]) -> Output {
29    let options = server.options(root);
30    Command::new(env!("CARGO_BIN_EXE_avril"))
31        .current_dir(cwd)
32        .arg("add")
33        .arg(format!("--jsr-registry={}", options.jsr_registry))
34        .arg(format!("--cache-dir={}", options.cache.display()))
35        .args(args)
36        .output()
37        .unwrap()
38}
39
40fn import<'a>(alias: &'a str, target: &'a str) -> Addition<'a> {
41    Addition::Import { alias, target }
42}
43
44/// The package bound to `binding` in a config's deps.
45fn dependency(source: &str, binding: &str) -> String {
46    let ValueKind::Object(root) = parse(source).unwrap().kind else {
47        panic!()
48    };
49    let ValueKind::Array(deps) = &root
50        .iter()
51        .find(|member| member.key == "deps")
52        .unwrap()
53        .value
54        .kind
55    else {
56        panic!()
57    };
58    let string = |value: &avril_runtime::config::Value| match &value.kind {
59        ValueKind::String(value) => value.clone(),
60        _ => panic!(),
61    };
62    deps.iter()
63        .find_map(|entry| match &entry.kind {
64            ValueKind::String(from) => (from.starts_with(&format!("jsr:{binding}@"))
65                || *from == format!("jsr:{binding}"))
66            .then(|| from.clone()),
67            ValueKind::Object(fields) => {
68                let field =
69                    |key: &str| &fields.iter().find(|field| field.key == key).unwrap().value;
70                (string(field("name")) == binding).then(|| string(field("from")))
71            }
72            _ => None,
73        })
74        .unwrap()
75}
76
77fn target(source: &str, alias: &str) -> String {
78    let ValueKind::Object(root) = parse(source).unwrap().kind else {
79        panic!()
80    };
81    let ValueKind::Object(imports) = &root
82        .iter()
83        .find(|member| member.key == "imports")
84        .unwrap()
85        .value
86        .kind
87    else {
88        panic!()
89    };
90    let ValueKind::String(value) = &imports
91        .iter()
92        .find(|member| member.key == alias)
93        .unwrap()
94        .value
95        .kind
96    else {
97        panic!()
98    };
99    value.clone()
100}
101
102#[test]
103fn additions_preserve_comments_other_entries_and_decoded_keys() {
104    for source in [
105        "",
106        "// last comment",
107        "{/* inside */}",
108        "imports: {/* keep */}",
109        "{imports: {old: './old.ts' /* old comment */}, scopes: {} /* scope comment */}",
110        "// heading\nimports: {\n  old: './old.ts', // trailing\n}, // end\n",
111    ] {
112        let edited = add(source, &import("alias", "jsr:@test/pkg@^1")).unwrap().0;
113        assert_eq!(target(&edited, "alias"), "jsr:@test/pkg@^1");
114        for comment in [
115            "// last comment",
116            "/* inside */",
117            "/* keep */",
118            "/* old comment */",
119            "/* scope comment */",
120            "// heading",
121            "// trailing",
122            "// end",
123        ] {
124            if source.contains(comment) {
125                assert!(edited.contains(comment));
126            }
127        }
128        if source.contains("old:") {
129            assert_eq!(target(&edited, "old"), "./old.ts");
130        }
131    }
132    let source = "imports: {'de\\u0070': /* choice */ 'jsr:@test/pkg@1' /* reason */}";
133    let (edited, change) = add(source, &import("dep", "jsr:@test/pkg@2")).unwrap();
134    assert_eq!(
135        change,
136        Change::Updated {
137            previous: Some("jsr:@test/pkg@1".into())
138        }
139    );
140    assert_eq!(
141        edited,
142        source.replace("'jsr:@test/pkg@1'", "'jsr:@test/pkg@2'")
143    );
144    assert_eq!(target(&edited, "dep"), "jsr:@test/pkg@2");
145    let edited = add("imports:{}", &import("lib/", "jsr:@test/pkg@^1/"))
146        .unwrap()
147        .0;
148    assert_eq!(target(&edited, "lib/"), "jsr:@test/pkg@^1/");
149}
150
151#[test]
152fn additions_work_with_implicit_commas_without_rewriting_existing_entries() {
153    let source = "name: 'app'\ndeps: [\n  'jsr:@test/old@1' // pinned\n  '../local'\n]\nimports: {\n  old: './old.ts'\n  other: './other.ts'\n}\n";
154    for newline in ["\n", "\r\n", "\r"] {
155        let source = source.replace('\n', newline);
156        let (edited, change) = add(&source, &import("alias", "jsr:@test/pkg@^1")).unwrap();
157        assert_eq!(change, Change::Added);
158        assert_eq!(target(&edited, "alias"), "jsr:@test/pkg@^1");
159        assert_eq!(target(&edited, "old"), "./old.ts");
160        assert_eq!(target(&edited, "other"), "./other.ts");
161        let addition = Addition::Dependency {
162            name: None,
163            package: "jsr:@test/pkg@2",
164        };
165        let (edited, change) = add(&edited, &addition).unwrap();
166        assert_eq!(change, Change::Added);
167        assert_eq!(dependency(&edited, "@test/pkg"), "jsr:@test/pkg@2");
168        assert_eq!(dependency(&edited, "@test/old"), "jsr:@test/old@1");
169        for kept in [
170            "name: 'app'",
171            "'jsr:@test/old@1' // pinned\n  '../local'",
172            "old: './old.ts'\n  other: './other.ts'",
173        ] {
174            assert!(edited.contains(&kept.replace('\n', newline)), "{edited:?}");
175        }
176    }
177}
178
179#[test]
180fn invalid_edits_do_not_replace_files_and_existing_modes_are_preserved() {
181    let dir = tempfile::tempdir().unwrap();
182    let path = dir.path().join("avril.conf");
183    fs::write(&path, "imports: {old: './old.ts'}").unwrap();
184    for (alias, target) in [
185        ("../dep", "npm:pkg@1"),
186        ("dep/", "jsr:@test/pkg@1"),
187        ("../dep", "jsr:@test/pkg@1"),
188        ("", "jsr:@test/pkg@1"),
189    ] {
190        assert!(add_file(&path, &import(alias, target)).is_err());
191        assert_eq!(
192            fs::read_to_string(&path).unwrap(),
193            "imports: {old: './old.ts'}"
194        );
195    }
196    #[cfg(unix)]
197    {
198        use std::os::unix::fs::PermissionsExt;
199        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
200        add_file(&path, &import("dep", "jsr:@test/pkg@1")).unwrap();
201        assert_eq!(
202            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
203            0o600
204        );
205    }
206}
207
208#[test]
209fn add_command_uses_the_nearest_config_without_serializing_it() {
210    let dir = tempfile::tempdir().unwrap();
211    fs::create_dir(dir.path().join("src")).unwrap();
212    fs::write(
213        dir.path().join("avril.conf"),
214        "// chosen by hand\nimports: {old: './old.ts'},\n",
215    )
216    .unwrap();
217    let server = registry();
218    let output = avril_add(
219        &server,
220        dir.path(),
221        &dir.path().join("src"),
222        &["dep", "jsr:@test/pkg@^1"],
223    );
224    assert!(
225        output.status.success(),
226        "{}",
227        String::from_utf8_lossy(&output.stderr)
228    );
229    let source = fs::read_to_string(dir.path().join("avril.conf")).unwrap();
230    assert!(source.starts_with("// chosen by hand\n"));
231    assert!(source.contains("old: './old.ts'"));
232    assert_eq!(dependency(&source, "dep"), "jsr:@test/pkg@^1");
233    assert!(!dir.path().join("src/avril.conf").exists());
234}
235
236#[cfg(unix)]
237#[test]
238fn config_outputs_do_not_follow_symlinks_or_open_fifos() {
239    use std::os::unix::fs::symlink;
240    let dir = tempfile::tempdir().unwrap();
241    let destination = dir.path().join("secret");
242    let path = dir.path().join("avril.conf");
243    fs::write(&destination, "sentinel").unwrap();
244    symlink(&destination, &path).unwrap();
245    assert!(add_file(&path, &import("dep", "jsr:@test/pkg@1")).is_err());
246    assert_eq!(fs::read_to_string(&destination).unwrap(), "sentinel");
247    fs::remove_file(&path).unwrap();
248    let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
249    assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
250    assert!(add_file(&path, &import("dep", "jsr:@test/pkg@1")).is_err());
251}
252
253#[test]
254fn editing_and_loading_malformed_configs_have_the_same_diagnostics() {
255    let base = url::Url::parse(if cfg!(windows) {
256        "file:///C:/package/avril.conf"
257    } else {
258        "file:///package/avril.conf"
259    })
260    .unwrap();
261    for source in [
262        "imports { }",
263        "name 'app'",
264        "[]",
265        "{imports: {old 'broken'}}",
266    ] {
267        let error = avril_runtime::package_config::PackageConfig::parse(source, &base).unwrap_err();
268        assert_eq!(
269            add(source, &import("dep", "jsr:@test/pkg@1")).unwrap_err(),
270            error.render("avril.conf", source)
271        );
272    }
273}
274
275#[cfg(unix)]
276#[test]
277fn add_does_not_shadow_an_untrusted_ancestor_config() {
278    use std::os::unix::fs::symlink;
279    let dir = tempfile::tempdir().unwrap();
280    fs::create_dir(dir.path().join("src")).unwrap();
281    fs::write(dir.path().join("other.conf"), "// sentinel").unwrap();
282    symlink("other.conf", dir.path().join("avril.conf")).unwrap();
283    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
284        .current_dir(dir.path().join("src"))
285        .args(["add", "dep", "jsr:@test/pkg@1"])
286        .output()
287        .unwrap();
288    assert!(!output.status.success());
289    assert!(String::from_utf8_lossy(&output.stderr).contains("Untrusted config"));
290    assert!(!dir.path().join("src/avril.conf").exists());
291    assert_eq!(
292        fs::read_to_string(dir.path().join("other.conf")).unwrap(),
293        "// sentinel"
294    );
295}
296
297#[test]
298fn additions_preserve_extension_fields() {
299    let source = r#"// project extensions
300custom: {nested: [null, true, 42], name: false}
301importMap: {specifierMap: {old: './old.ts'}}
302tasks: {build: {command: 'echo built', custom: {color: 'green'}}}
303deps: [{from: 'jsr:@test/pkg@1', name: 'pkg', custom: {optional: true}}]
304"#;
305    let dir = tempfile::tempdir().unwrap();
306    let path = dir.path().join("avril.conf");
307    fs::write(&path, source).unwrap();
308    add_file(&path, &import("helper", "jsr:@std/assert@^1")).unwrap();
309    let edited = fs::read_to_string(&path).unwrap();
310    assert!(edited.starts_with(source), "{edited}");
311    assert_eq!(target(&edited, "helper"), "jsr:@std/assert@^1");
312
313    add_file(
314        &path,
315        &Addition::Dependency {
316            name: Some("pkg"),
317            package: "jsr:@test/pkg@2",
318        },
319    )
320    .unwrap();
321    let updated = fs::read_to_string(&path).unwrap();
322    assert_eq!(updated, edited.replace("@test/pkg@1", "@test/pkg@2"));
323    assert_eq!(dependency(&updated, "pkg"), "jsr:@test/pkg@2");
324}
325
326#[test]
327fn additions_preserve_metadata_dependencies_and_scope_maps() {
328    for source in [
329        "name: '@app/demo', version: '0.1.0', description: 'Demo', exports: './mod.ts'",
330        "name: '@app/demo', imports: {/* maps */}",
331        "name: '@app/demo', deps: ['../unread' /* local */, {from: '../math', name: 'math'}], scopes: {'./test/': {helper: './helper.ts'}}",
332    ] {
333        let edited = add(source, &import("assert", "jsr:@std/assert@^1"))
334            .unwrap()
335            .0;
336        assert_eq!(target(&edited, "assert"), "jsr:@std/assert@^1");
337        for unchanged in [
338            "name: '@app/demo'",
339            "version: '0.1.0'",
340            "description: 'Demo'",
341            "exports: './mod.ts'",
342            "/* maps */",
343            "deps: ['../unread' /* local */, {from: '../math', name: 'math'}]",
344            "scopes: {'./test/': {helper: './helper.ts'}}",
345        ] {
346            if source.contains(unchanged) {
347                assert!(edited.contains(unchanged));
348            }
349        }
350    }
351}
352
353#[test]
354fn add_rejects_invalid_fields_and_reserved_package_names_without_replacing_files() {
355    let dir = tempfile::tempdir().unwrap();
356    let path = dir.path().join("avril.conf");
357    for (source, alias, message) in [
358        ("imports: []", "dep", "Expected an object"),
359        ("name: 'math', exports: './mod.ts'", "math", "conflicts"),
360        (
361            "deps: [{from: '../math', name: 'math'}]",
362            "math/vector",
363            "conflicts",
364        ),
365    ] {
366        fs::write(&path, source).unwrap();
367        assert!(
368            add_file(&path, &import(alias, "jsr:@test/pkg@1"))
369                .unwrap_err()
370                .to_string()
371                .contains(message)
372        );
373        assert_eq!(fs::read_to_string(&path).unwrap(), source);
374    }
375}
376
377#[test]
378fn add_without_an_alias_depends_on_the_whole_package_and_aliases_choose_the_field() {
379    let dir = tempfile::tempdir().unwrap();
380    let server = registry();
381    let add = |args: &[&str]| {
382        let output = avril_add(&server, dir.path(), dir.path(), args);
383        assert!(
384            output.status.success(),
385            "{}",
386            String::from_utf8_lossy(&output.stderr)
387        );
388        String::from_utf8(output.stderr).unwrap()
389    };
390    let source = || fs::read_to_string(dir.path().join("avril.conf")).unwrap();
391    assert!(add(&["jsr:@test/pkg"]).contains("Added "));
392    assert_eq!(dependency(&source(), "@test/pkg"), "jsr:@test/pkg@^2.0.0");
393    assert!(add(&["jsr:@test/pkg@^1/sub"]).contains("Updated "));
394    assert!(add(&["@test/pkg", "jsr:@test/pkg@^1"]).contains("Unchanged "));
395    assert!(add(&["pkg", "jsr:@test/pkg@^1"]).contains("Added "));
396    assert!(add(&["sub", "jsr:@test/pkg@^1/sub"]).contains("Added "));
397    assert!(add(&["prefix/", "jsr:@test/pkg@^1/"]).contains("Added "));
398    assert!(add(&["slashed", "jsr:/@test/pkg@^1"]).contains("Added "));
399    let source = source();
400    assert_eq!(dependency(&source, "@test/pkg"), "jsr:@test/pkg@^1");
401    assert_eq!(dependency(&source, "pkg"), "jsr:@test/pkg@^1");
402    assert_eq!(dependency(&source, "slashed"), "jsr:@test/pkg@^1");
403    assert_eq!(target(&source, "sub"), "jsr:@test/pkg@^1/sub");
404    assert_eq!(target(&source, "prefix/"), "jsr:@test/pkg@^1/");
405}
406
407#[test]
408fn edits_report_whether_the_alias_was_added_updated_or_already_mapped() {
409    let source = "imports: {blocked: null, same: 'jsr:@test/\\u0070kg@1' /* kept */}";
410    for (alias, change) in [
411        ("fresh", Change::Added),
412        ("blocked", Change::Updated { previous: None }),
413        ("same", Change::Unchanged),
414    ] {
415        let (edited, actual) = add(source, &import(alias, "jsr:@test/pkg@1")).unwrap();
416        assert_eq!(actual, change, "{alias}");
417        assert_eq!(edited == source, change == Change::Unchanged, "{alias}");
418        assert_eq!(target(&edited, alias), "jsr:@test/pkg@1");
419    }
420}
421
422#[test]
423fn add_command_reports_the_mapping_on_stdout_and_its_status_on_stderr() {
424    let dir = tempfile::tempdir().unwrap();
425    fs::create_dir(dir.path().join("src")).unwrap();
426    fs::write(dir.path().join("avril.conf"), "").unwrap();
427    let server = registry();
428    let add = |cwd: &str, args: &[&str]| {
429        let output = avril_add(&server, dir.path(), &dir.path().join(cwd), args);
430        assert!(
431            output.status.success(),
432            "{}",
433            String::from_utf8_lossy(&output.stderr)
434        );
435        (
436            String::from_utf8(output.stdout).unwrap(),
437            String::from_utf8(output.stderr).unwrap(),
438        )
439    };
440    let config = dir.path().join("avril.conf");
441    for (cwd, args, stdout, stderr) in [
442        (
443            ".",
444            &["jsr:@test/pkg@1"][..],
445            "\"@test/pkg\": \"jsr:@test/pkg@1\" in avril.conf\n".to_owned(),
446            "Added ",
447        ),
448        (
449            "src",
450            &["@test/pkg", "jsr:@test/pkg@2"],
451            format!(
452                "\"@test/pkg\": \"jsr:@test/pkg@1\" → \"jsr:@test/pkg@2\" in {}\n",
453                config.display()
454            ),
455            "Updated ",
456        ),
457        (
458            ".",
459            &["--config=avril.conf", "@test/pkg", "jsr:@test/pkg@2"],
460            "\"@test/pkg\": \"jsr:@test/pkg@2\" in avril.conf\n".to_owned(),
461            "Unchanged ",
462        ),
463    ] {
464        let (actual_stdout, actual_stderr) = add(cwd, args);
465        assert_eq!(actual_stdout, stdout, "{args:?}");
466        assert!(
467            actual_stderr.contains(stderr),
468            "{args:?}: {actual_stderr:?}"
469        );
470    }
471}
472
473#[cfg(unix)]
474#[test]
475fn add_command_escapes_terminal_controls_in_config_paths() {
476    let dir = tempfile::tempdir().unwrap();
477    let server = registry();
478    let output = avril_add(
479        &server,
480        dir.path(),
481        dir.path(),
482        &["--config=a\x1b[2J\nb/avril.conf", "jsr:@test/pkg@1"],
483    );
484    assert!(
485        output.status.success(),
486        "{}",
487        String::from_utf8_lossy(&output.stderr)
488    );
489    assert_eq!(
490        String::from_utf8(output.stdout).unwrap(),
491        "\"@test/pkg\": \"jsr:@test/pkg@1\" in a\\u{1b}[2J\\nb/avril.conf\n"
492    );
493}
494
495#[test]
496fn unversioned_targets_gain_a_caret_range_that_keeps_their_subpath() {
497    for (target, pinned) in [
498        ("jsr:@std/path", "jsr:@std/path@^1.2.3"),
499        ("jsr:@std/path/posix", "jsr:@std/path@^1.2.3/posix"),
500        ("jsr:@std/path/", "jsr:@std/path@^1.2.3/"),
501        ("jsr:/@std/path/posix/", "jsr:@std/path@^1.2.3/posix/"),
502    ] {
503        let name = unversioned(target).unwrap().unwrap();
504        assert_eq!(name, "@std/path");
505        assert_eq!(with_caret(target, &name, "1.2.3"), pinned);
506    }
507    for target in [
508        "jsr:@std/path@1",
509        "jsr:@std/path@*/posix",
510        "jsr:/@std/path@^1/",
511    ] {
512        assert_eq!(unversioned(target).unwrap(), None, "{target}");
513    }
514}
515
516#[test]
517fn add_selects_the_newest_stable_release_unless_prereleases_are_requested() {
518    let server = registry();
519    for version in ["2.1.0", "3.0.0-rc.1"] {
520        server.package(
521            "@test/pkg",
522            version,
523            &[("mod.js", "export const version = 'later';")],
524            &[(".", "./mod.js")],
525        );
526    }
527    server.package(
528        "@test/next",
529        "1.0.0-beta.1",
530        &[("mod.js", "export {};")],
531        &[(".", "./mod.js")],
532    );
533    {
534        let mut files = server.files.lock().unwrap();
535        let index = files.get_mut("/@test/pkg/meta.json").unwrap();
536        let mut parsed: serde_json::Value = serde_json::from_slice(index).unwrap();
537        parsed["versions"]["2.1.0"]["yanked"] = true.into();
538        *index = serde_json::to_vec(&parsed).unwrap();
539    }
540    let dir = tempfile::tempdir().unwrap();
541    let add = |args: &[&str]| avril_add(&server, dir.path(), dir.path(), args);
542    let source = || fs::read_to_string(dir.path().join("avril.conf")).unwrap();
543    assert!(add(&["jsr:@test/pkg"]).status.success());
544    assert_eq!(dependency(&source(), "@test/pkg"), "jsr:@test/pkg@^2.0.0");
545    assert!(add(&["--pre", "jsr:@test/pkg"]).status.success());
546    assert_eq!(
547        dependency(&source(), "@test/pkg"),
548        "jsr:@test/pkg@^3.0.0-rc.1"
549    );
550    let before = source();
551    for (args, message) in [
552        (&["jsr:@test/next"][..], "--pre allows prereleases"),
553        (
554            &["--pre", "jsr:@test/pkg@1"],
555            "only applies to targets without a version",
556        ),
557    ] {
558        let output = add(args);
559        assert!(!output.status.success(), "{args:?}");
560        assert!(
561            String::from_utf8_lossy(&output.stderr).contains(message),
562            "{args:?}: {}",
563            String::from_utf8_lossy(&output.stderr)
564        );
565        assert_eq!(source(), before);
566    }
567    assert!(add(&["--pre", "jsr:@test/next"]).status.success());
568    assert_eq!(
569        dependency(&source(), "@test/next"),
570        "jsr:@test/next@^1.0.0-beta.1"
571    );
572}
573
574#[test]
575fn add_caches_what_the_mapping_can_import_so_programs_run_offline() {
576    let server = registry();
577    server.package(
578        "@test/other",
579        "1.0.0",
580        &[("unused.js", "export {};")],
581        &[("./unused", "./unused.js")],
582    );
583    let dir = tempfile::tempdir().unwrap();
584    for args in [&["jsr:@test/pkg@1"][..], &["next/", "jsr:@test/pkg/"]] {
585        let output = avril_add(&server, dir.path(), dir.path(), args);
586        assert!(
587            output.status.success(),
588            "{}",
589            String::from_utf8_lossy(&output.stderr)
590        );
591    }
592    let lock = fixture::read_lock(dir.path());
593    assert_eq!(lock["specifiers"]["jsr:@test/pkg@1"], "1.0.0");
594    assert_eq!(lock["specifiers"]["jsr:@test/pkg@^2.0.0"], "2.0.0");
595    fs::write(
596        dir.path().join("main.js"),
597        "import {version} from '@test/pkg'; import {sub} from '@test/pkg/sub'; import {sub as next} from 'next/sub'; console.log(version, sub, next);",
598    )
599    .unwrap();
600    let requests = server.requests.lock().unwrap().len();
601    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
602        .arg("run")
603        .current_dir(dir.path())
604        .arg(format!("--jsr-registry={}", server.url))
605        .arg(format!(
606            "--cache-dir={}",
607            dir.path().join("cache").display()
608        ))
609        .args(["--offline", "--frozen", "main.js"])
610        .output()
611        .unwrap();
612    assert!(
613        output.status.success(),
614        "{}",
615        String::from_utf8_lossy(&output.stderr)
616    );
617    assert_eq!(output.stdout, b"1.0.0 true true\n");
618    assert_eq!(server.requests.lock().unwrap().len(), requests);
619    let output = avril_add(
620        &server,
621        dir.path(),
622        dir.path(),
623        &["--offline", "jsr:@test/other"],
624    );
625    assert!(!output.status.success());
626    assert_eq!(server.requests.lock().unwrap().len(), requests);
627}
628
629#[test]
630fn add_caches_registry_targets_without_the_consumers_maps_and_keeps_edits_on_failure() {
631    let server = registry();
632    server.package(
633        "@test/root",
634        "1.0.0",
635        &[
636            ("mod.js", "export const root = 1;"),
637            ("slash.js", "export const slash = 1;"),
638        ],
639        &[(".", "./mod.js"), ("./slash/", "./slash.js")],
640    );
641    server.package(
642        "@test/broken",
643        "1.0.0",
644        &[("mod.js", "import './missing.js';")],
645        &[(".", "./mod.js")],
646    );
647    let dir = tempfile::tempdir().unwrap();
648    fs::write(
649        dir.path().join("avril.conf"),
650        "imports: {'jsr:@test/pkg@1': null}",
651    )
652    .unwrap();
653    let add = |args: &[&str]| avril_add(&server, dir.path(), dir.path(), args);
654    let lock = || fixture::read_lock(dir.path());
655    for args in [
656        &["lib", "jsr:@test/pkg@1"][..],
657        &["root/", "jsr:@test/root@1/"],
658        &["two", "jsr:@test/pkg@2"],
659    ] {
660        let output = add(args);
661        assert!(
662            output.status.success(),
663            "{args:?}: {}",
664            String::from_utf8_lossy(&output.stderr)
665        );
666    }
667    assert_eq!(lock()["specifiers"]["jsr:@test/pkg@1"], "1.0.0");
668    assert_eq!(lock()["specifiers"]["jsr:@test/root@1"], "1.0.0");
669    let requests = server.requests.lock().unwrap().len();
670    let output = add(&["--offline", "latest", "jsr:@test/pkg"]);
671    assert!(
672        output.status.success(),
673        "{}",
674        String::from_utf8_lossy(&output.stderr)
675    );
676    assert_eq!(server.requests.lock().unwrap().len(), requests);
677    let source = fs::read_to_string(dir.path().join("avril.conf")).unwrap();
678    assert_eq!(dependency(&source, "latest"), "jsr:@test/pkg@^2.0.0");
679    // An unchanged edit still caches, refilling a removed cache.
680    fs::remove_dir_all(dir.path().join("cache")).unwrap();
681    let output = add(&["lib", "jsr:@test/pkg@1"]);
682    assert!(
683        String::from_utf8_lossy(&output.stderr).contains("Unchanged "),
684        "{}",
685        String::from_utf8_lossy(&output.stderr)
686    );
687    let fetched = |path: &str| {
688        let requests = server.requests.lock().unwrap();
689        requests.iter().filter(|request| *request == path).count()
690    };
691    assert_eq!(fetched("/@test/pkg/1.0.0/mod.js"), 2);
692    assert_eq!(fetched("/@test/root/1.0.0/mod.js"), 1);
693    assert_eq!(fetched("/@test/root/1.0.0/slash.js"), 1);
694    let output = add(&["broken", "jsr:@test/broken@1"]);
695    assert!(!output.status.success());
696    assert!(String::from_utf8_lossy(&output.stderr).contains("missing.js"));
697    let source = fs::read_to_string(dir.path().join("avril.conf")).unwrap();
698    assert_eq!(dependency(&source, "broken"), "jsr:@test/broken@1");
699}
700
701#[test]
702fn dependency_additions_insert_replace_and_keep_formatting() {
703    let dependency_of = |name, package| Addition::Dependency { name, package };
704    for (source, name) in [
705        ("", None),
706        ("// keep\r\nname: 'app',\r\n", Some("pkg")),
707        ("deps: [\n  '../local', // local\n]", None),
708        ("deps: []", Some("@test/pkg")),
709    ] {
710        let (edited, change) = add(source, &dependency_of(name, "jsr:@test/pkg@2")).unwrap();
711        assert_eq!(change, Change::Added, "{source:?}");
712
713        assert_eq!(
714            dependency(&edited, name.unwrap_or("@test/pkg")),
715            "jsr:@test/pkg@2"
716        );
717        for kept in ["// keep", "name: 'app',\r\n", "  '../local', // local\n"] {
718            assert_eq!(edited.contains(kept), source.contains(kept));
719        }
720        if source.contains("\r\n") {
721            assert!(!edited.replace("\r\n", "").contains('\n'), "{edited:?}");
722        }
723    }
724    for (source, name) in [
725        ("deps: ['jsr:@test/pkg@1' /* pinned */, '../local']", None),
726        (
727            "deps: [{from: 'jsr:@test/pkg@1', name: 'pkg'}]",
728            Some("pkg"),
729        ),
730        ("deps: [{from: '../local', name: 'pkg'}]", Some("pkg")),
731    ] {
732        let (edited, change) = add(source, &dependency_of(name, "jsr:@test/pkg@2")).unwrap();
733        let previous = if source.contains("jsr:") {
734            "jsr:@test/pkg@1"
735        } else {
736            "../local"
737        };
738        assert_eq!(
739            change,
740            Change::Updated { previous: Some(previous.into()) },
741            "{source}"
742        );
743        assert_eq!(
744            edited,
745            source.replace(&format!("'{previous}'"), "'jsr:@test/pkg@2'")
746        );
747        let (unchanged, change) = add(&edited, &dependency_of(name, "jsr:@test/pkg@2")).unwrap();
748        assert_eq!((unchanged, change), (edited, Change::Unchanged));
749    }
750    for (source, addition, message) in [
751        (
752            "",
753            dependency_of(None, "jsr:@test/pkg@2/sub"),
754            "whole packages",
755        ),
756        (
757            "",
758            dependency_of(None, "jsr:@test/pkg@2/"),
759            "whole packages",
760        ),
761        (
762            "",
763            dependency_of(None, "jsr:@Test/pkg@2"),
764            "Invalid JSR package name",
765        ),
766        (
767            "",
768            dependency_of(Some("a/b"), "jsr:@test/pkg@2"),
769            "binding name",
770        ),
771        (
772            "imports: {'@test/pkg/x': './x.ts'}",
773            dependency_of(None, "jsr:@test/pkg@2"),
774            "conflicts",
775        ),
776    ] {
777        let error = add(source, &addition).unwrap_err();
778        assert!(error.contains(message), "{source}: {error}");
779    }
780}
781
782#[test]
783fn renaming_a_package_keeps_the_binding_it_replaces() {
784    let source = "deps: ['jsr:@test/old@1', '../local']";
785    let addition = Addition::Dependency {
786        name: Some("@test/old"),
787        package: "jsr:@test/new@2",
788    };
789    let (edited, change) = add(source, &addition).unwrap();
790    assert_eq!(
791        change,
792        Change::Updated {
793            previous: Some("jsr:@test/old@1".into())
794        }
795    );
796    assert_eq!(dependency(&edited, "@test/old"), "jsr:@test/new@2");
797    assert!(edited.starts_with("deps: ["));
798    assert!(edited.ends_with(", '../local']"));
799}
800
801#[test]
802fn npm_targets_gain_caret_ranges_keep_subpaths_and_reject_versions_in_deps() {
803    use avril_runtime::config_edit::{selects_whole_package, whole_package};
804    for (target, pinned, whole) in [
805        ("npm:left-pad", "npm:left-pad@^1.2.3", true),
806        ("npm:left-pad/sub", "npm:left-pad@^1.2.3/sub", false),
807        ("npm:@scope/pkg", "npm:@scope/pkg@^1.2.3", true),
808    ] {
809        let name = unversioned(target).unwrap().unwrap();
810        assert_eq!(with_caret(target, &name, "1.2.3"), pinned);
811        assert_eq!(selects_whole_package(pinned).unwrap(), whole);
812    }
813    for (target, whole) in [
814        ("npm:left-pad@1", true),
815        ("npm:left-pad@*/sub", false),
816        ("npm:left-pad@latest", true),
817    ] {
818        assert_eq!(unversioned(target).unwrap(), None, "{target}");
819        assert_eq!(selects_whole_package(target).unwrap(), whole, "{target}");
820    }
821    assert!(!selects_whole_package("npm:left-pad/sub").unwrap());
822    assert!(!selects_whole_package("npm:left-pad/").unwrap());
823    assert_eq!(
824        whole_package("npm:left-pad@^1/sub").unwrap(),
825        "npm:left-pad@^1"
826    );
827    assert!(whole_package("npm:left-pad/sub").is_ok());
828}
829
830#[test]
831fn add_warns_and_skips_node_builtin_imports_for_jsr() {
832    let server = Registry::new();
833    server.package(
834        "@test/env",
835        "1.0.0",
836        &[
837            (
838                "mod.ts",
839                "import 'node:fs';\nimport {helper} from './helper.ts';\nexport const value = helper;",
840            ),
841            ("helper.ts", "export const helper = 'helped';"),
842        ],
843        &[(".", "./mod.ts")],
844    );
845    server.package(
846        "@test/typed",
847        "1.0.0",
848        &[(
849            "mod.ts",
850            "import type {Stats} from 'node:fs';\nexport const value = 1;",
851        )],
852        &[(".", "./mod.ts")],
853    );
854    let dir = tempfile::tempdir().unwrap();
855    let output = avril_add(&server, dir.path(), dir.path(), &["jsr:@test/env@1"]);
856    assert!(
857        output.status.success(),
858        "{}",
859        String::from_utf8_lossy(&output.stderr)
860    );
861    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
862    assert!(
863        stderr.contains("Warning") && stderr.contains("node:fs"),
864        "{stderr:?}"
865    );
866    assert!(
867        server
868            .requests
869            .lock()
870            .unwrap()
871            .iter()
872            .any(|path| path == "/@test/env/1.0.0/helper.ts"),
873        "traversal continues past the builtin edge"
874    );
875    let output = avril_add(&server, dir.path(), dir.path(), &["jsr:@test/typed@1"]);
876    assert!(
877        output.status.success(),
878        "{}",
879        String::from_utf8_lossy(&output.stderr)
880    );
881    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
882    assert!(
883        !stderr.contains("node:"),
884        "erased type-only imports warn nothing: {stderr:?}"
885    );
886}