char/avril

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

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

Charlotte Somdeslop: consolidate public type and registry test utils7325e8c

main
100.5 KiB3077 linesraw
1#[path = "common/registry.rs"]
2mod fixture;
3
4use avril_runtime::registry::{
5    PackageStore,
6    npm::resolution::{NpmResolver, NpmRootRequest, ResolutionMode, ResolvedNpmModule},
7};
8use fixture::Registry;
9use futures::executor::block_on;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13fn publish_stub(server: &Registry, name: &str, manifest: serde_json::Value, paths: &[&str]) {
14    let mut sources: Vec<_> = paths.iter().map(|path| (*path, "export {};")).collect();
15    if !paths.contains(&"index.js") {
16        sources.push(("index.js", "export default 0;"));
17    }
18    server.publish_package(name, "1.0.0", manifest, &sources);
19}
20
21fn resolve_types(resolver: &NpmResolver, request: &str) -> ResolvedNpmModule {
22    block_on(resolver.resolve_request(request, ResolutionMode::TypeImport)).unwrap()
23}
24
25#[test]
26fn type_resolution_can_read_declarations_without_changing_execution_behavior() {
27    use avril_runtime::registry::npm::package::NpmModuleKind;
28
29    let server = Registry::new();
30    publish_stub(
31        &server,
32        "declared-types",
33        serde_json::json!({"types": "./index.d.ts"}),
34        &["index.d.ts"],
35    );
36    let dir = tempfile::tempdir().unwrap();
37    let resolver = server.resolver(dir.path());
38    let root = resolve(&resolver, "npm:declared-types@1");
39    let declaration = resolver
40        .resolve_from_file("./index.d.ts", &root.path, ResolutionMode::TypeImport)
41        .unwrap();
42    assert_eq!(declaration.format, NpmModuleKind::Declaration);
43    assert!(
44        resolver
45            .resolve_from_file("./index.d.ts", &root.path, ResolutionMode::Import)
46            .is_err()
47    );
48}
49
50#[test]
51fn package_declared_types_and_declaration_extension_substitution_resolve() {
52    use avril_runtime::registry::npm::package::NpmModuleKind;
53
54    let server = Registry::new();
55    publish_stub(
56        &server,
57        "types-entry",
58        serde_json::json!({
59            "types": "types/main.d.ts",
60            "typings": "./types/legacy.d.ts"
61        }),
62        &[
63            "types/main.d.ts",
64            "types/legacy.d.ts",
65            "src/value.d.ts",
66            "index.d.ts",
67        ],
68    );
69    let dir = tempfile::tempdir().unwrap();
70    let resolver = server.resolver(dir.path());
71    let entry = resolve_types(&resolver, "npm:types-entry@1");
72    assert!(entry.path.ends_with("types/main.d.ts"));
73    assert_eq!(entry.format, NpmModuleKind::Declaration);
74    let reference = resolver
75        .resolve_from_file("../src/value.js", &entry.path, ResolutionMode::TypeImport)
76        .unwrap();
77    assert!(reference.path.ends_with("src/value.d.ts"));
78    assert_eq!(reference.format, NpmModuleKind::Declaration);
79    assert!(
80        resolver
81            .resolve_from_file("../src/value.js", &entry.path, ResolutionMode::Import)
82            .is_err()
83    );
84}
85
86#[test]
87fn types_versions_requires_an_explicit_backend_version_and_only_affects_types() {
88    let server = Registry::new();
89    publish_stub(
90        &server,
91        "types-versions-version",
92        serde_json::json!({
93            "types": "./types/index.d.ts",
94            "typesVersions": { ">=5.0.0 <6.0.0": { "types/index.d.ts": ["ts5/index.d.ts"] } }
95        }),
96        &["index.js", "types/index.d.ts", "ts5/index.d.ts"],
97    );
98    let dir = tempfile::tempdir().unwrap();
99    let resolver = server.resolver(dir.path());
100    let runtime = resolve(&resolver, "npm:types-versions-version@1");
101    assert!(runtime.path.ends_with("index.js"));
102    let request = "npm:types-versions-version@1";
103    let missing =
104        block_on(resolver.resolve_request(request, ResolutionMode::TypeImport)).unwrap_err();
105    assert!(missing.to_string().contains("set_typescript_version"));
106    assert!(resolver.set_typescript_version("latest").is_err());
107    resolver.set_typescript_version("5.4.3").unwrap();
108    let typed = block_on(resolver.resolve_request(request, ResolutionMode::TypeImport)).unwrap();
109    assert!(typed.path.ends_with("ts5/index.d.ts"));
110    assert!(
111        resolve(&resolver, "npm:types-versions-version@1")
112            .path
113            .ends_with("index.js")
114    );
115}
116
117#[test]
118fn types_versions_uses_first_matching_range_exact_and_best_prefix_patterns() {
119    let server = Registry::new();
120    publish_stub(
121        &server,
122        "types-versions-patterns",
123        serde_json::json!({
124            "types": "./types/main.d.ts",
125            "typings": "./types/legacy.d.ts",
126            "typesVersions": {
127                ">=5.0.0": {
128                    "types/main.d.ts": ["ts5/missing.d.ts", "ts5/main.d.ts"],
129                    "feature/*": ["ts5/general/*.d.ts"],
130                    "feature/*.js": ["ts5/suffix/*.d.ts"],
131                    "feature/specific/*": ["ts5/specific/*.d.ts"],
132                    "exact": ["ts5/exact.d.ts"]
133                },
134                ">=4.0.0": { "types/main.d.ts": ["ts4/main.d.ts"] }
135            }
136        }),
137        &[
138            "types/main.d.ts",
139            "types/legacy.d.ts",
140            "ts5/main.d.ts",
141            "ts4/main.d.ts",
142            "ts5/general/other.d.ts",
143            "ts5/specific/item.d.ts",
144            "ts5/exact.d.ts",
145            "ts5/general/other.js.d.ts",
146            "ts5/suffix/other.d.ts",
147            "feature/specific/fallback.d.ts",
148        ],
149    );
150    let dir = tempfile::tempdir().unwrap();
151    let resolver = server.resolver(dir.path());
152    resolver.set_typescript_version("5.4.2").unwrap();
153    let roots = block_on(resolver.prepare(&[NpmRootRequest {
154        request: "npm:types-versions-patterns@1".into(),
155        context: BTreeMap::new(),
156    }]))
157    .unwrap();
158    for (subpath, target) in [
159        ("", "ts5/main.d.ts"),
160        ("/feature/other", "ts5/general/other.d.ts"),
161        ("/feature/specific/item", "ts5/specific/item.d.ts"),
162        ("/feature/other.js", "ts5/general/other.js.d.ts"),
163        ("/exact", "ts5/exact.d.ts"),
164        (
165            "/feature/specific/fallback",
166            "feature/specific/fallback.d.ts",
167        ),
168    ] {
169        let request = format!("npm:types-versions-patterns@1{subpath}");
170        let resolved = resolver
171            .resolve_in(&roots, &request, ResolutionMode::TypeImport)
172            .unwrap();
173        assert!(
174            resolved.path.ends_with(target),
175            "{request}: {}",
176            resolved.path.display()
177        );
178    }
179}
180
181#[test]
182fn types_versions_no_match_and_malformed_metadata_fall_back_without_affecting_runtime() {
183    let server = Registry::new();
184    publish_stub(
185        &server,
186        "types-versions-fallback",
187        serde_json::json!({
188            "types": "./types/index.d.ts",
189            "typesVersions": {
190                "not a range": { "*": ["ts5/*.d.ts"] },
191                ">=5.0.0": { "*": ["../escape.d.ts"] }
192            }
193        }),
194        &["index.js", "types/index.d.ts"],
195    );
196    let dir = tempfile::tempdir().unwrap();
197    let resolver = server.resolver(dir.path());
198    assert!(
199        resolve(&resolver, "npm:types-versions-fallback@1")
200            .path
201            .ends_with("index.js")
202    );
203    let module = block_on(
204        resolver.resolve_request("npm:types-versions-fallback@1", ResolutionMode::TypeImport),
205    )
206    .unwrap();
207    assert!(module.path.ends_with("types/index.d.ts"));
208
209    publish_stub(
210        &server,
211        "types-versions-no-match",
212        serde_json::json!({
213            "types": "./types/base.d.ts",
214            "typesVersions": { ">=5.0.0": { "*": ["ts5/*"] } }
215        }),
216        &["types/base.d.ts", "ts5/index.d.ts"],
217    );
218    let no_match = block_on(
219        resolver.resolve_request("npm:types-versions-no-match@1", ResolutionMode::TypeImport),
220    );
221    assert!(no_match.is_err());
222    resolver.set_typescript_version("4.9.5").unwrap();
223    let no_match = resolve_types(&resolver, "npm:types-versions-no-match@1");
224    assert!(no_match.path.ends_with("types/base.d.ts"));
225}
226
227#[test]
228fn exports_precede_types_versions_and_do_not_require_a_backend_version() {
229    let server = Registry::new();
230    publish_stub(
231        &server,
232        "types-versions-exports",
233        serde_json::json!({
234            "exports": { ".": { "types": "./exports.d.ts", "default": "./index.js" } },
235            "typesVersions": { "*": { "*": ["mapped/*"] } }
236        }),
237        &["index.js", "exports.d.ts", "mapped/index.d.ts"],
238    );
239    let dir = tempfile::tempdir().unwrap();
240    let resolver = server.resolver(dir.path());
241    let typed = resolve_types(&resolver, "npm:types-versions-exports@1");
242    assert!(typed.path.ends_with("exports.d.ts"));
243}
244
245#[test]
246fn types_versions_matches_typescript_prereleases_like_the_compiler() {
247    let server = Registry::new();
248    let cases = [
249        ("wildcard", "*", "7.1.0-dev.20261013.1", true),
250        ("partial_lower", ">=4.9", "4.9.0-beta", true),
251        ("partial_upper", "<4.9", "4.9.0-beta", false),
252        ("explicit_upper", "<4.9.0", "4.9.0-beta", true),
253        ("explicit_lower", ">=4.9.0", "4.9.0-beta", false),
254        ("spaced", ">= 4.9 < 5", "4.9.0-beta", true),
255        (
256            "prerelease_bound",
257            ">=7.1.0-dev.20261013.1",
258            "7.1.0-dev.20261013.2",
259            true,
260        ),
261        ("tilde_upper", "~4.9", "4.10.0-beta", true),
262        ("tilde_lower", "~4.9", "4.9.0-beta", false),
263        ("caret_upper", "^4.9", "5.0.0-beta", true),
264        ("hyphen_upper", "4.9 - 5", "6.0.0-beta", true),
265        ("hyphen_exact", "4.9 - 5.0.0-beta", "5.0.0", false),
266        ("prefixed_partial", "v4.9", "4.9.0", false),
267        (
268            "wildcard_prerelease_lower",
269            "4.9.x-beta",
270            "4.9.0-alpha",
271            true,
272        ),
273        (
274            "wildcard_prerelease_upper",
275            "4.9.x-beta",
276            "4.10.0-beta",
277            false,
278        ),
279    ];
280    for (name, range, version, mapped) in cases {
281        let package = format!("types-versions-prerelease-{name}");
282        let mut types_versions = serde_json::Map::new();
283        types_versions.insert(
284            range.to_owned(),
285            serde_json::json!({ "types/base.d.ts": ["mapped.d.ts"] }),
286        );
287        publish_stub(
288            &server,
289            &package,
290            serde_json::json!({
291                "types": "./types/base.d.ts",
292                "typesVersions": types_versions
293            }),
294            &["types/base.d.ts", "mapped.d.ts"],
295        );
296        let dir = tempfile::tempdir().unwrap();
297        let resolver = server.resolver(dir.path());
298        resolver.set_typescript_version(version).unwrap();
299        let request = format!("npm:{package}@1");
300        let resolved = resolve_types(&resolver, &request);
301        assert_eq!(
302            resolved.path.ends_with("mapped.d.ts"),
303            mapped,
304            "{range} with {version}"
305        );
306    }
307}
308
309#[test]
310fn legacy_type_subpaths_resolve_declarations_without_runtime_files() {
311    let server = Registry::new();
312    publish_stub(
313        &server,
314        "legacy-types-subpaths",
315        serde_json::json!({}),
316        &["sub.d.ts", "nested/index.d.ts"],
317    );
318    let dir = tempfile::tempdir().unwrap();
319    let resolver = server.resolver(dir.path());
320    let roots = block_on(resolver.prepare(&[NpmRootRequest {
321        request: "npm:legacy-types-subpaths@1".into(),
322        context: BTreeMap::new(),
323    }]))
324    .unwrap();
325    assert!(
326        resolver
327            .resolve_in(
328                &roots,
329                "npm:legacy-types-subpaths@1/sub",
330                ResolutionMode::TypeImport
331            )
332            .unwrap()
333            .path
334            .ends_with("sub.d.ts")
335    );
336    assert!(
337        resolver
338            .resolve_in(
339                &roots,
340                "npm:legacy-types-subpaths@1/nested",
341                ResolutionMode::TypeImport
342            )
343            .unwrap()
344            .path
345            .ends_with("nested/index.d.ts")
346    );
347}
348
349#[test]
350fn malformed_type_fields_do_not_break_execution_but_fail_type_resolution() {
351    let server = Registry::new();
352    publish_stub(
353        &server,
354        "malformed-types",
355        serde_json::json!({"types": 17}),
356        &["index.js"],
357    );
358    let dir = tempfile::tempdir().unwrap();
359    let resolver = server.resolver(dir.path());
360    let runtime = resolve(&resolver, "npm:malformed-types@1");
361    assert!(runtime.path.ends_with("index.js"));
362    let error =
363        block_on(resolver.resolve_request("npm:malformed-types@1", ResolutionMode::TypeImport))
364            .unwrap_err();
365    assert!(error.to_string().contains("types"), "{error}");
366}
367
368#[test]
369fn hash_import_type_targets_use_declaration_substitution() {
370    let server = Registry::new();
371    publish_stub(
372        &server,
373        "hash-types",
374        serde_json::json!({"imports": {"#types": "./types/alias.js"}}),
375        &["types/alias.d.ts"],
376    );
377    let dir = tempfile::tempdir().unwrap();
378    let resolver = server.resolver(dir.path());
379    let root = resolve(&resolver, "npm:hash-types@1");
380    let declaration = resolver
381        .resolve_from_file("#types", &root.path, ResolutionMode::TypeImport)
382        .unwrap();
383    assert!(declaration.path.ends_with("types/alias.d.ts"));
384}
385
386#[test]
387fn declaration_import_equals_require_uses_require_type_conditions() {
388    let server = Registry::new();
389    publish_stub(
390        &server,
391        "require-types-dep",
392        serde_json::json!({
393            "exports": {".": {"require": {"types": "./index.d.cts", "default": "./index.cjs"}}}
394        }),
395        &["index.d.cts"],
396    );
397    publish_stub(
398        &server,
399        "require-types-owner",
400        serde_json::json!({
401            "types": "./index.d.ts",
402            "dependencies": {"require-types-dep": "1.0.0"}
403        }),
404        &["index.d.ts"],
405    );
406    let dir = tempfile::tempdir().unwrap();
407    let resolver = server.resolver(dir.path());
408    let declaration = resolve_types(&resolver, "npm:require-types-owner@1");
409    let dep = resolver
410        .resolve_from_file(
411            "require-types-dep",
412            &declaration.path,
413            ResolutionMode::TypeRequire,
414        )
415        .unwrap();
416    assert!(dep.path.ends_with("index.d.cts"));
417}
418
419#[test]
420fn declaration_index_is_the_legacy_fallback_without_declared_entry_fields() {
421    let server = Registry::new();
422    publish_stub(
423        &server,
424        "types-index-fallback",
425        serde_json::json!({"main": "./runtime.js"}),
426        &["runtime.js", "index.d.ts"],
427    );
428    let dir = tempfile::tempdir().unwrap();
429    let resolver = server.resolver(dir.path());
430    let module = resolve_types(&resolver, "npm:types-index-fallback@1");
431    assert!(module.path.ends_with("index.d.ts"));
432}
433
434#[test]
435fn exports_type_conditions_keep_import_require_and_encapsulation() {
436    let server = Registry::new();
437    publish_stub(
438        &server,
439        "types-conditions",
440        serde_json::json!({
441            "exports": {
442                ".": {
443                    "import": {"types": "./types/import.d.ts", "default": "./index.js"},
444                    "require": {"types": "./types/require.d.cts", "default": "./index.cjs"}
445                },
446                "./public": {"types": "./types/public.d.ts", "default": "./public.js"}
447            }
448        }),
449        &[
450            "types/import.d.ts",
451            "types/require.d.cts",
452            "types/public.d.ts",
453            "private.d.ts",
454        ],
455    );
456    let dir = tempfile::tempdir().unwrap();
457    let resolver = server.resolver(dir.path());
458    let roots = block_on(resolver.prepare(&[NpmRootRequest {
459        request: "npm:types-conditions@1".into(),
460        context: BTreeMap::new(),
461    }]))
462    .unwrap();
463    let import = resolver
464        .resolve_in(&roots, "npm:types-conditions@1", ResolutionMode::TypeImport)
465        .unwrap();
466    let require = resolver
467        .resolve_in(
468            &roots,
469            "npm:types-conditions@1",
470            ResolutionMode::TypeRequire,
471        )
472        .unwrap();
473    let require_from_file = resolver
474        .resolve_from_file(
475            &require.path.display().to_string(),
476            &import.path,
477            ResolutionMode::TypeRequire,
478        )
479        .unwrap();
480    assert!(require_from_file.path.ends_with("types/require.d.cts"));
481    assert!(import.path.ends_with("types/import.d.ts"));
482    assert!(require.path.ends_with("types/require.d.cts"));
483    let public = resolver
484        .resolve_in(
485            &roots,
486            "npm:types-conditions@1/public",
487            ResolutionMode::TypeImport,
488        )
489        .unwrap();
490    assert!(public.path.ends_with("types/public.d.ts"));
491    assert!(
492        resolver
493            .resolve_in(
494                &roots,
495                "npm:types-conditions@1/private.d.ts",
496                ResolutionMode::TypeImport
497            )
498            .is_err()
499    );
500}
501
502#[test]
503fn warm_instance_checks_its_own_nested_metadata() {
504    let server = Registry::new();
505    server.publish_package(
506        "instance-metadata",
507        "1.0.0",
508        serde_json::json!({
509            "exports": "./lib/index.js",
510            "dependencies": {"instance-metadata": "1.0.0"}
511        }),
512        &[
513            ("lib/package.json", r#"{"type":"module"}"#),
514            ("lib/index.js", "export default 1;"),
515        ],
516    );
517    let dir = tempfile::tempdir().unwrap();
518    let resolver = server.resolver(dir.path());
519    let module = resolve(&resolver, "npm:instance-metadata@1");
520    resolver.store().save().unwrap();
521    let metadata = module.path.parent().unwrap().join("package.json");
522    // Replace only this instance's entry, leaving the shared backing intact.
523    std::fs::remove_file(&metadata).unwrap();
524    std::fs::write(&metadata, r#"{"type":"commonjs"}"#).unwrap();
525    let mut options = server.options(dir.path());
526    options.offline = true;
527    options.frozen = true;
528    let warm = fixture::resolver(options);
529    let error = block_on(warm.resolve_request("npm:instance-metadata@1", ResolutionMode::Import))
530        .unwrap_err()
531        .to_string();
532    assert!(error.contains("integrity mismatch"), "{error}");
533    assert!(error.contains("lib/package.json"), "{error}");
534}
535
536#[test]
537fn nested_metadata_is_consumed_only_when_resolution_needs_it() {
538    use avril_runtime::registry::npm::package::NpmModuleKind;
539
540    let server = Registry::new();
541    server.publish_package(
542        "lazy-scopes",
543        "1.0.0",
544        serde_json::json!({}),
545        &[
546            ("broken/package.json", "{oops"),
547            ("broken/mod.js", "export default 1;"),
548            ("broken/mod.mjs", "export default 1;"),
549            ("broken/mod.cjs", "module.exports = 1;"),
550            ("broken/mod.ts", "export default 1;"),
551            ("broken/mod.d.ts", "export declare const x: number;"),
552            ("bad-type/package.json", r#"{"type":"invalid"}"#),
553            ("bad-type/mod.js", "export default 1;"),
554        ],
555    );
556    let dir = tempfile::tempdir().unwrap();
557    let resolver = server.resolver(dir.path());
558    let root = resolve(&resolver, "npm:lazy-scopes@1");
559    for (file, kind) in [
560        ("mod.mjs", NpmModuleKind::Esm),
561        ("mod.cjs", NpmModuleKind::CommonJs),
562        ("mod.ts", NpmModuleKind::TypeScript),
563    ] {
564        let module = resolver
565            .resolve_from_file(
566                &format!("./broken/{file}"),
567                &root.path,
568                ResolutionMode::Import,
569            )
570            .unwrap();
571        assert_eq!(module.format, kind);
572    }
573    assert_eq!(
574        resolver
575            .check_access(&root.path.parent().unwrap().join("broken/mod.d.ts"))
576            .unwrap(),
577        NpmModuleKind::Declaration
578    );
579    for (path, message) in [
580        ("./broken/mod.js", "Invalid nested"),
581        ("./bad-type/mod.js", "Unsupported package type"),
582    ] {
583        let error = resolver
584            .resolve_from_file(path, &root.path, ResolutionMode::Import)
585            .unwrap_err();
586        assert!(error.to_string().contains(message), "{error}");
587    }
588    let error = resolver
589        .resolve_from_file("./broken", &root.path, ResolutionMode::Require)
590        .unwrap_err();
591    assert!(error.to_string().contains("Invalid nested"), "{error}");
592}
593
594#[test]
595fn export_warming_propagates_required_scope_errors_but_skips_unsupported_files() {
596    let server = Registry::new();
597    let dir = tempfile::tempdir().unwrap();
598    let resolver = server.resolver(dir.path());
599    for (name, fields) in [
600        ("legacy-scopes", serde_json::json!({})),
601        (
602            "mapped-scopes",
603            serde_json::json!({"exports": {"./*": "./*"}}),
604        ),
605    ] {
606        server.publish_package(
607            name,
608            "1.0.0",
609            fields,
610            &[
611                ("safe/package.json", "{oops"),
612                ("safe/mod.mjs", "export default 1;"),
613                ("safe/addon.node", "unused"),
614                ("broken/package.json", "{oops"),
615                ("broken/mod.js", "export default 1;"),
616            ],
617        );
618        assert_eq!(
619            block_on(resolver.export_requests(&format!("npm:{name}@1/safe"))).unwrap(),
620            [format!("npm:{name}@1/safe/mod.mjs")]
621        );
622        let error =
623            block_on(resolver.export_requests(&format!("npm:{name}@1/broken"))).unwrap_err();
624        assert!(error.to_string().contains("Invalid nested"), "{error}");
625    }
626}
627
628#[test]
629fn nested_scopes_share_verified_metadata_and_preserve_type_inheritance() {
630    use avril_runtime::registry::npm::package::NpmModuleKind;
631
632    let server = Registry::new();
633    server.publish_package(
634        "scope-snapshot",
635        "1.0.0",
636        serde_json::json!({}),
637        &[
638            ("cjs/package.json", r#"{"type":"commonjs"}"#),
639            ("cjs/sub/package.json", r#"{"main":"./chosen.js"}"#),
640            ("cjs/sub/chosen.js", "module.exports = 1;"),
641            ("cjs/sub/index.js", "module.exports = 2;"),
642            ("cjs/sub/esm/package.json", r#"{"type":"module"}"#),
643            ("cjs/sub/esm/mod.js", "export default 3;"),
644        ],
645    );
646    let dir = tempfile::tempdir().unwrap();
647    let resolver = server.resolver(dir.path());
648    let root = resolve(&resolver, "npm:scope-snapshot@1");
649    let chosen = resolver
650        .resolve_from_file("./cjs/sub/chosen.js", &root.path, ResolutionMode::Import)
651        .unwrap();
652    assert_eq!(chosen.format, NpmModuleKind::CommonJs);
653    let esm = resolver
654        .resolve_from_file("./cjs/sub/esm/mod.js", &root.path, ResolutionMode::Import)
655        .unwrap();
656    assert_eq!(esm.format, NpmModuleKind::Esm);
657    let metadata = chosen.path.parent().unwrap().join("package.json");
658    std::fs::write(&metadata, r#"{"type":"module","main":"./index.js"}"#).unwrap();
659    // Already consumed metadata remains one verified snapshot, not new disk input.
660    let directory = resolver
661        .resolve_from_file("./cjs/sub", &root.path, ResolutionMode::Require)
662        .unwrap();
663    assert_eq!(directory.path, chosen.path);
664    assert_eq!(directory.format, NpmModuleKind::CommonJs);
665    assert!(resolver.audit().is_err());
666}
667
668#[test]
669fn unused_nested_metadata_tampering_is_detected_on_use_and_by_audit() {
670    let server = Registry::new();
671    server.publish_package(
672        "unused-scope",
673        "1.0.0",
674        serde_json::json!({}),
675        &[
676            ("sub/package.json", r#"{"type":"module"}"#),
677            ("sub/mod.js", "export default 1;"),
678        ],
679    );
680    let dir = tempfile::tempdir().unwrap();
681    let first = server.resolver(dir.path());
682    let root = resolve(&first, "npm:unused-scope@1");
683    first.store().save().unwrap();
684    let metadata = root.path.parent().unwrap().join("sub/package.json");
685    std::fs::write(&metadata, r#"{"type":"commonjs"}"#).unwrap();
686    let mut options = server.options(dir.path());
687    options.offline = true;
688    options.frozen = true;
689    let warm = fixture::resolver(options);
690    let root = resolve(&warm, "npm:unused-scope@1");
691    let error = warm
692        .resolve_from_file("./sub/mod.js", &root.path, ResolutionMode::Import)
693        .unwrap_err();
694    assert!(error.to_string().contains("integrity mismatch"), "{error}");
695    assert!(error.to_string().contains("sub/package.json"), "{error}");
696    assert!(warm.audit().is_err());
697}
698
699#[test]
700fn cached_nested_scopes_do_not_bypass_live_authority() {
701    use avril_runtime::caps::{Capabilities, Category, Initial};
702
703    let server = Registry::new();
704    server.publish_package(
705        "scope-authority",
706        "1.0.0",
707        serde_json::json!({}),
708        &[
709            ("sub/package.json", r#"{"type":"module","main":"./mod.js"}"#),
710            ("sub/mod.js", "export default 1;"),
711        ],
712    );
713    let dir = tempfile::tempdir().unwrap();
714    let version_url = format!("{}scope-authority/1.0.0/", server.url);
715    let mut initial = Initial::new(true);
716    initial
717        .grant(Category::JsExec, false, &version_url, dir.path())
718        .unwrap();
719    let caps = Capabilities::new(initial, dir.path().to_owned());
720    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
721        .unwrap()
722        .with_capabilities(caps.clone());
723    let resolver = NpmResolver::new(Arc::new(store));
724    let root = resolve(&resolver, "npm:scope-authority@1");
725    resolver
726        .resolve_from_file("./sub", &root.path, ResolutionMode::Require)
727        .unwrap();
728    caps.pledge(vec![(Category::JsExec, None, vec![version_url])])
729        .unwrap();
730    for specifier in ["./sub", "./sub/mod.js"] {
731        let error = resolver
732            .resolve_from_file(specifier, &root.path, ResolutionMode::Require)
733            .unwrap_err();
734        assert!(error.to_string().contains("PermissionDenied"), "{error}");
735    }
736}
737
738#[test]
739fn full_audit_obeys_live_source_authority() {
740    use avril_runtime::caps::{Capabilities, Category, Initial};
741
742    let server = Registry::new();
743    server.publish_package("audit-authority", "1.0.0", serde_json::json!({}), &[]);
744    let dir = tempfile::tempdir().unwrap();
745    let version_url = format!("{}audit-authority/1.0.0/", server.url);
746    let mut initial = Initial::new(true);
747    initial
748        .grant(Category::JsExec, false, &version_url, dir.path())
749        .unwrap();
750    let caps = Capabilities::new(initial, dir.path().to_owned());
751    let store = PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
752        .unwrap()
753        .with_capabilities(caps.clone());
754    let resolver = NpmResolver::new(Arc::new(store));
755    resolve(&resolver, "npm:audit-authority@1");
756    resolver.audit().unwrap();
757    caps.pledge(vec![(Category::JsExec, None, vec![version_url])])
758        .unwrap();
759    assert!(
760        resolver
761            .audit()
762            .unwrap_err()
763            .to_string()
764            .contains("PermissionDenied")
765    );
766}
767
768fn identity(server: &Registry, name: &str, version: &str) -> String {
769    format!("{}|{name}|{version}", server.url.as_str())
770}
771
772fn resolve(resolver: &NpmResolver, spec: &str) -> ResolvedNpmModule {
773    block_on(resolver.resolve_request(spec, ResolutionMode::Import)).unwrap()
774}
775
776#[test]
777fn module_entry_hint_is_file_scoped_and_independent_of_resolution_order() {
778    use avril_runtime::registry::npm::package::NpmModuleKind;
779
780    let server = Registry::new();
781    for module in ["lib/entry.js", "./lib/entry.js"] {
782        let dir = tempfile::tempdir().unwrap();
783        server.publish_npm(
784            "dual-entry",
785            "1.0.0",
786            &[
787                (
788                    "package.json",
789                    &serde_json::json!({
790                        "name": "dual-entry", "version": "1.0.0",
791                        "module": module, "main": "./lib/main.js"
792                    })
793                    .to_string(),
794                ),
795                ("lib/entry.js", "export default 42;"),
796                ("lib/main.js", "module.exports = 7;"),
797                ("lib/sibling.js", "module.exports = 8;"),
798            ],
799        );
800        let resolver = server.resolver(dir.path());
801        let direct = resolve(&resolver, "npm:dual-entry@1/lib/entry.js");
802        assert_eq!(direct.format, NpmModuleKind::Esm);
803        assert_eq!(
804            resolver.check_access(&direct.path).unwrap(),
805            NpmModuleKind::Esm
806        );
807        let root = resolve(&resolver, "npm:dual-entry@1");
808        assert_eq!(root.path, direct.path);
809        assert_eq!(root.format, NpmModuleKind::Esm);
810        for mode in [ResolutionMode::Import, ResolutionMode::Require] {
811            let same = resolver
812                .resolve_from_file("./entry.js", &root.path, mode)
813                .unwrap();
814            assert_eq!(same.path, root.path);
815            assert_eq!(same.format, NpmModuleKind::Esm);
816            let sibling = resolver
817                .resolve_from_file("./sibling.js", &root.path, mode)
818                .unwrap();
819            assert_eq!(sibling.format, NpmModuleKind::CommonJs);
820        }
821        let main = block_on(resolver.resolve_request("npm:dual-entry@1", ResolutionMode::Require))
822            .unwrap();
823        assert!(main.path.ends_with("lib/main.js"));
824        assert_eq!(main.format, NpmModuleKind::CommonJs);
825    }
826}
827
828#[test]
829fn entry_hints_preserve_explicit_extensions_and_main_package_types() {
830    use avril_runtime::registry::npm::package::NpmModuleKind;
831
832    let server = Registry::new();
833    for (fields, file, expected) in [
834        (
835            serde_json::json!({"module": "entry.cjs"}),
836            "entry.cjs",
837            NpmModuleKind::CommonJs,
838        ),
839        (
840            serde_json::json!({"main": "entry.js"}),
841            "entry.js",
842            NpmModuleKind::CommonJs,
843        ),
844        (
845            serde_json::json!({"main": "entry.js", "type": "module"}),
846            "entry.js",
847            NpmModuleKind::Esm,
848        ),
849        (
850            serde_json::json!({"main": "entry.mjs"}),
851            "entry.mjs",
852            NpmModuleKind::Esm,
853        ),
854        (
855            serde_json::json!({"main": "entry.cjs", "type": "module"}),
856            "entry.cjs",
857            NpmModuleKind::CommonJs,
858        ),
859    ] {
860        let dir = tempfile::tempdir().unwrap();
861        let mut package = serde_json::json!({"name": "entry-format", "version": "1.0.0"});
862        package
863            .as_object_mut()
864            .unwrap()
865            .extend(fields.as_object().unwrap().clone());
866        server.publish_npm(
867            "entry-format",
868            "1.0.0",
869            &[("package.json", &package.to_string()), (file, "")],
870        );
871        let resolver = server.resolver(dir.path());
872        let entry = resolve(&resolver, "npm:entry-format@1");
873        assert!(entry.path.ends_with(file));
874        assert_eq!(entry.format, expected);
875        assert_eq!(resolver.check_access(&entry.path).unwrap(), expected);
876    }
877}
878
879#[test]
880fn roots_alias_subpaths_share_prepared_packages() {
881    let server = Registry::new();
882    server.publish_package(
883        "lib",
884        "1.0.0",
885        serde_json::json!({"exports": {".": "./mod.js", "./sub": "./sub.js"}}),
886        &[
887            ("mod.js", "export default 1;"),
888            ("sub.js", "export default 2;"),
889        ],
890    );
891    let dir = tempfile::tempdir().unwrap();
892    let resolver = server.resolver(dir.path());
893    block_on(resolver.prepare(&[NpmRootRequest {
894        request: "npm:lib@^1/sub".into(),
895        context: BTreeMap::new(),
896    }]))
897    .unwrap();
898    let sub = resolve(&resolver, "npm:lib@^1/sub");
899    assert!(sub.path.ends_with("sub.js"));
900    let root = resolve(&resolver, "npm:lib@^1");
901    assert!(root.path.ends_with("mod.js"));
902    assert_eq!(sub.instance, root.instance);
903}
904
905#[test]
906fn transitive_versions_coexist_per_consumer() {
907    let server = Registry::new();
908    server.publish_package("util", "1.0.0", serde_json::json!({}), &[]);
909    server.publish_package("util", "2.0.0", serde_json::json!({}), &[]);
910    server.publish_package(
911        "lib",
912        "1.0.0",
913        serde_json::json!({"dependencies": {"util": "^2.0.0"}}),
914        &[("index.js", "export * from 'util';")],
915    );
916    server.publish_package(
917        "app",
918        "1.0.0",
919        serde_json::json!({"dependencies": {"lib": "^1.0.0", "util": "^1.0.0"}}),
920        &[("index.js", "export * from 'lib'; export * from 'util';")],
921    );
922    let dir = tempfile::tempdir().unwrap();
923    let resolver = server.resolver(dir.path());
924    block_on(resolver.prepare(&[NpmRootRequest {
925        request: "npm:app@1".into(),
926        context: BTreeMap::new(),
927    }]))
928    .unwrap();
929    let app = resolve(&resolver, "npm:app@1");
930    let from_app = resolver
931        .resolve_from_file("util", &app.path, ResolutionMode::Import)
932        .unwrap();
933    assert!(from_app.path.to_string_lossy().contains("/util/1.0.0/"));
934    let lib = resolver
935        .resolve_from_file("lib", &app.path, ResolutionMode::Import)
936        .unwrap();
937    let from_lib = resolver
938        .resolve_from_file("util", &lib.path, ResolutionMode::Import)
939        .unwrap();
940    assert!(from_lib.path.to_string_lossy().contains("/util/2.0.0/"));
941    assert_ne!(from_app.path, from_lib.path);
942}
943
944#[test]
945fn shared_dependencies_share_instances() {
946    let server = Registry::new();
947    server.publish_package("shared", "1.0.0", serde_json::json!({}), &[]);
948    server.publish_package(
949        "a",
950        "1.0.0",
951        serde_json::json!({"dependencies": {"shared": "^1.0.0"}}),
952        &[],
953    );
954    server.publish_package(
955        "b",
956        "1.0.0",
957        serde_json::json!({"dependencies": {"shared": "~1.0.0"}}),
958        &[],
959    );
960    let dir = tempfile::tempdir().unwrap();
961    let resolver = server.resolver(dir.path());
962    block_on(resolver.prepare(&[
963        NpmRootRequest {
964            request: "npm:a@1".into(),
965            context: BTreeMap::new(),
966        },
967        NpmRootRequest {
968            request: "npm:b@1".into(),
969            context: BTreeMap::new(),
970        },
971    ]))
972    .unwrap();
973    let a = resolve(&resolver, "npm:a@1");
974    let b = resolve(&resolver, "npm:b@1");
975    let from_a = resolver
976        .resolve_from_file("shared", &a.path, ResolutionMode::Import)
977        .unwrap();
978    let from_b = resolver
979        .resolve_from_file("shared", &b.path, ResolutionMode::Import)
980        .unwrap();
981    assert_eq!(from_a.path, from_b.path);
982    assert_eq!(from_a.instance, from_b.instance);
983}
984
985#[test]
986fn peer_bindings_share_providers_and_ignore_import_order() {
987    let server = Registry::new();
988    server.publish_package("react", "18.2.0", serde_json::json!({}), &[]);
989    server.publish_package(
990        "plugin",
991        "1.0.0",
992        serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}),
993        &[],
994    );
995    server.publish_package(
996        "app",
997        "1.0.0",
998        serde_json::json!({"dependencies": {"react": "18.2.0", "plugin": "1.0.0"}}),
999        &[],
1000    );
1001    let context = BTreeMap::from([("react".into(), identity(&server, "react", "18.2.0"))]);
1002    let dir = tempfile::tempdir().unwrap();
1003    let resolver = server.resolver(dir.path());
1004    block_on(resolver.prepare(&[
1005        NpmRootRequest {
1006            request: "npm:app@1".into(),
1007            context: context.clone(),
1008        },
1009        NpmRootRequest {
1010            request: "npm:plugin@1".into(),
1011            context: context.clone(),
1012        },
1013    ]))
1014    .unwrap();
1015    let app = resolve(&resolver, "npm:app@1");
1016    let plugin = resolve(&resolver, "npm:plugin@1");
1017    let via_app = resolver
1018        .resolve_from_file("react", &app.path, ResolutionMode::Import)
1019        .unwrap();
1020    let via_plugin = resolver
1021        .resolve_from_file("react", &plugin.path, ResolutionMode::Import)
1022        .unwrap();
1023    assert_eq!(via_app.path, via_plugin.path);
1024
1025    let dir2 = tempfile::tempdir().unwrap();
1026    let reversed = server.resolver(dir2.path());
1027    block_on(reversed.prepare(&[
1028        NpmRootRequest {
1029            request: "npm:plugin@1".into(),
1030            context: context.clone(),
1031        },
1032        NpmRootRequest {
1033            request: "npm:app@1".into(),
1034            context,
1035        },
1036    ]))
1037    .unwrap();
1038    let first = resolve(&resolver, "npm:plugin@1");
1039    let second = resolve(&reversed, "npm:plugin@1");
1040    assert_eq!(
1041        second.instance, first.instance,
1042        "instance ids must not depend on root order"
1043    );
1044}
1045
1046#[test]
1047fn peer_contexts_fork_instances() {
1048    let server = Registry::new();
1049    server.publish_package("react", "17.0.0", serde_json::json!({}), &[]);
1050    server.publish_package("react", "18.2.0", serde_json::json!({}), &[]);
1051    server.publish_package(
1052        "plugin",
1053        "1.0.0",
1054        serde_json::json!({"peerDependencies": {"react": "*"}}),
1055        &[],
1056    );
1057    server.publish_package(
1058        "h17",
1059        "1.0.0",
1060        serde_json::json!({"dependencies": {"react": "17.0.0", "plugin": "1.0.0"}}),
1061        &[],
1062    );
1063    server.publish_package(
1064        "h18",
1065        "1.0.0",
1066        serde_json::json!({"dependencies": {"react": "18.2.0", "plugin": "1.0.0"}}),
1067        &[],
1068    );
1069    let dir = tempfile::tempdir().unwrap();
1070    let resolver = server.resolver(dir.path());
1071    block_on(resolver.prepare(&[
1072        NpmRootRequest {
1073            request: "npm:h17@1".into(),
1074            context: BTreeMap::new(),
1075        },
1076        NpmRootRequest {
1077            request: "npm:h18@1".into(),
1078            context: BTreeMap::new(),
1079        },
1080    ]))
1081    .unwrap();
1082    let h17 = resolve(&resolver, "npm:h17@1");
1083    let h18 = resolve(&resolver, "npm:h18@1");
1084    let p17 = resolver
1085        .resolve_from_file("plugin", &h17.path, ResolutionMode::Import)
1086        .unwrap();
1087    let p18 = resolver
1088        .resolve_from_file("plugin", &h18.path, ResolutionMode::Import)
1089        .unwrap();
1090    assert_ne!(p17.instance, p18.instance);
1091    assert_ne!(p17.path, p18.path);
1092    let r17 = resolver
1093        .resolve_from_file("react", &p17.path, ResolutionMode::Import)
1094        .unwrap();
1095    let r18 = resolver
1096        .resolve_from_file("react", &p18.path, ResolutionMode::Import)
1097        .unwrap();
1098    assert!(r17.path.to_string_lossy().contains("/react/17.0.0/"));
1099    assert!(r18.path.to_string_lossy().contains("/react/18.2.0/"));
1100}
1101
1102#[test]
1103fn ordinary_cycles_terminate() {
1104    let server = Registry::new();
1105    server.publish_package(
1106        "a",
1107        "1.0.0",
1108        serde_json::json!({"dependencies": {"b": "1.0.0"}}),
1109        &[],
1110    );
1111    server.publish_package(
1112        "b",
1113        "1.0.0",
1114        serde_json::json!({"dependencies": {"a": "1.0.0"}}),
1115        &[],
1116    );
1117    let dir = tempfile::tempdir().unwrap();
1118    let resolver = server.resolver(dir.path());
1119    block_on(resolver.prepare(&[NpmRootRequest {
1120        request: "npm:a@1".into(),
1121        context: BTreeMap::new(),
1122    }]))
1123    .unwrap();
1124    let a = resolve(&resolver, "npm:a@1");
1125    let b = resolver
1126        .resolve_from_file("b", &a.path, ResolutionMode::Import)
1127        .unwrap();
1128    let back = resolver
1129        .resolve_from_file("a", &b.path, ResolutionMode::Import)
1130        .unwrap();
1131    assert_eq!(back.path, a.path);
1132}
1133
1134#[test]
1135fn absent_optional_peers_are_skipped() {
1136    let server = Registry::new();
1137    server.publish_package(
1138        "op",
1139        "1.0.0",
1140        serde_json::json!({
1141            "peerDependencies": {"ghost": "^1.0.0"},
1142            "peerDependenciesMeta": {"ghost": {"optional": true}},
1143        }),
1144        &[],
1145    );
1146    let dir = tempfile::tempdir().unwrap();
1147    let resolver = server.resolver(dir.path());
1148    block_on(resolver.prepare(&[NpmRootRequest {
1149        request: "npm:op@1".into(),
1150        context: BTreeMap::new(),
1151    }]))
1152    .unwrap();
1153    let module = resolve(&resolver, "npm:op@1");
1154    assert_eq!(module.instance, identity(&server, "op", "1.0.0"));
1155}
1156
1157#[test]
1158fn exports_select_exact_patterns_and_arrays() {
1159    let server = Registry::new();
1160    server.publish_package(
1161        "exp",
1162        "1.0.0",
1163        serde_json::json!({"exports": {
1164            ".": "./mod.js",
1165            "./feature": "./feature.js",
1166            "./features/*.js": "./mapped/*.js",
1167            "./nested": {"avril": "./avril.js", "default": "./default.js"},
1168            "./ordered": [{"avril": "./ordered-avril.js"}, "./ordered-fallback.js"],
1169        }}),
1170        &[
1171            ("mod.js", "export default 1;"),
1172            ("feature.js", "export default 2;"),
1173            ("mapped/thing.js", "export default 3;"),
1174            ("avril.js", "export default 4;"),
1175            ("default.js", "export default 5;"),
1176            ("ordered-avril.js", "export default 6;"),
1177            ("ordered-fallback.js", "export default 7;"),
1178        ],
1179    );
1180    let dir = tempfile::tempdir().unwrap();
1181    let resolver = server.resolver(dir.path());
1182    block_on(resolver.prepare(&[NpmRootRequest {
1183        request: "npm:exp@1".into(),
1184        context: BTreeMap::new(),
1185    }]))
1186    .unwrap();
1187    for (request, file) in [
1188        ("npm:exp@1", "mod.js"),
1189        ("npm:exp@1/feature", "feature.js"),
1190        ("npm:exp@1/features/thing.js", "mapped/thing.js"),
1191        ("npm:exp@1/nested", "avril.js"),
1192        ("npm:exp@1/ordered", "ordered-avril.js"),
1193    ] {
1194        assert!(
1195            resolve(&resolver, request).path.ends_with(file),
1196            "{request}"
1197        );
1198    }
1199    assert!(
1200        block_on(resolver.resolve_request("npm:exp@1/private", ResolutionMode::Import))
1201            .unwrap_err()
1202            .to_string()
1203            .contains("no export")
1204    );
1205}
1206
1207#[test]
1208fn null_exports_block_and_mixed_exports_fail() {
1209    let server = Registry::new();
1210    server.publish_package(
1211        "nulled",
1212        "1.0.0",
1213        serde_json::json!({"exports": {".": "./mod.js", "./blocked": null}}),
1214        &[
1215            ("mod.js", "export default 1;"),
1216            ("blocked.js", "export default 2;"),
1217        ],
1218    );
1219    server.publish_package(
1220        "mixed",
1221        "1.0.0",
1222        serde_json::json!({"exports": {".": "./mod.js", "import": "./mod.js"}}),
1223        &[("mod.js", "export default 1;")],
1224    );
1225    let dir = tempfile::tempdir().unwrap();
1226    let resolver = server.resolver(dir.path());
1227    block_on(resolver.prepare(&[NpmRootRequest {
1228        request: "npm:nulled@1".into(),
1229        context: BTreeMap::new(),
1230    }]))
1231    .unwrap();
1232    assert!(
1233        block_on(resolver.resolve_request("npm:nulled@1/blocked", ResolutionMode::Import))
1234            .unwrap_err()
1235            .to_string()
1236            .contains("blocked")
1237    );
1238    assert!(
1239        block_on(resolver.prepare(&[NpmRootRequest {
1240            request: "npm:mixed@1".into(),
1241            context: BTreeMap::new(),
1242        }]))
1243        .unwrap_err()
1244        .to_string()
1245        .contains("mixes")
1246    );
1247}
1248
1249#[test]
1250fn module_kinds_allow_cjs_for_require_wrappers_but_reject_json_native() {
1251    let server = Registry::new();
1252    server.publish_package(
1253        "cjs",
1254        "1.0.0",
1255        serde_json::json!({"type": "commonjs"}),
1256        &[("index.js", "module.exports = 1;")],
1257    );
1258    server.publish_package(
1259        "esm",
1260        "1.0.0",
1261        serde_json::json!({"type": "module"}),
1262        &[("index.js", "export default 1;")],
1263    );
1264    server.publish_package(
1265        "legacy",
1266        "1.0.0",
1267        serde_json::json!({"exports": {".": "./main.cjs", "./typed": "./typed.cts"}}),
1268        &[
1269            ("main.cjs", "module.exports = 1;"),
1270            ("typed.cts", "export default 1;"),
1271        ],
1272    );
1273    server.publish_package(
1274        "data",
1275        "1.0.0",
1276        serde_json::json!({"exports": {".": "./data.json"}}),
1277        &[("data.json", "{}")],
1278    );
1279    server.publish_package(
1280        "native",
1281        "1.0.0",
1282        serde_json::json!({"exports": {".": "./binding.node"}}),
1283        &[("binding.node", "binary")],
1284    );
1285    server.publish_package(
1286        "nested",
1287        "1.0.0",
1288        serde_json::json!({"type": "commonjs"}),
1289        &[
1290            ("index.js", "module.exports = 1;"),
1291            ("esm/package.json", "{\"type\": \"module\"}"),
1292            ("esm/mod.js", "export default 1;"),
1293        ],
1294    );
1295    server.publish_package(
1296        "types",
1297        "1.0.0",
1298        serde_json::json!({"exports": {".": "./index.d.ts"}}),
1299        &[
1300            ("index.js", "export default 0;"),
1301            ("index.d.ts", "export declare const x: number;"),
1302        ],
1303    );
1304    let dir = tempfile::tempdir().unwrap();
1305    let resolver = server.resolver(dir.path());
1306    for root in [
1307        "npm:cjs@1",
1308        "npm:esm@1",
1309        "npm:legacy@1",
1310        "npm:data@1",
1311        "npm:native@1",
1312        "npm:nested@1",
1313        "npm:types@1",
1314    ] {
1315        block_on(resolver.prepare(&[NpmRootRequest {
1316            request: root.into(),
1317            context: BTreeMap::new(),
1318        }]))
1319        .unwrap();
1320    }
1321    for (specifier, rel) in [
1322        ("npm:cjs@1", "index.js"),
1323        ("npm:esm@1", "index.js"),
1324        ("npm:legacy@1", "main.cjs"),
1325        ("npm:data@1", "data.json"),
1326    ] {
1327        let path = if specifier == "npm:data@1" {
1328            let registry = std::fs::read_dir(dir.path().join(".avril/npm/default"))
1329                .unwrap()
1330                .next()
1331                .unwrap()
1332                .unwrap()
1333                .path();
1334            registry.join("data/1.0.0/data.json")
1335        } else {
1336            resolve(&resolver, specifier).path
1337        };
1338        assert!(path.ends_with(rel));
1339        let original = std::fs::read(&path).unwrap();
1340        std::fs::write(&path, b"tampered").unwrap();
1341        let error = resolver.npm_source(&path).unwrap_err();
1342        assert!(error.to_string().contains(rel), "{error}");
1343        std::fs::write(path, original).unwrap();
1344    }
1345    assert!(
1346        block_on(resolver.resolve_request("npm:cjs@1", ResolutionMode::Import))
1347            .unwrap()
1348            .format
1349            == avril_runtime::registry::npm::package::NpmModuleKind::CommonJs
1350    );
1351    assert!(
1352        block_on(resolver.resolve_request("npm:legacy@1", ResolutionMode::Import))
1353            .unwrap()
1354            .format
1355            == avril_runtime::registry::npm::package::NpmModuleKind::CommonJs
1356    );
1357    // Explicit `.cts` is CommonJS (transpiled preserving CJS) for `require`
1358    // and ESM wrappers, even when its content uses ESM syntax (which then
1359    // fails at execution, not resolution).
1360    assert!(
1361        block_on(resolver.resolve_request("npm:legacy@1/typed", ResolutionMode::Import))
1362            .unwrap()
1363            .format
1364            == avril_runtime::registry::npm::package::NpmModuleKind::CommonJs
1365    );
1366    assert!(
1367        block_on(resolver.resolve_request("npm:data@1", ResolutionMode::Import))
1368            .unwrap_err()
1369            .to_string()
1370            .contains("JSON")
1371    );
1372    assert!(
1373        block_on(resolver.resolve_request("npm:native@1", ResolutionMode::Import))
1374            .unwrap_err()
1375            .to_string()
1376            .contains("Native")
1377    );
1378    let nested = resolve(&resolver, "npm:nested@1/esm/mod.js");
1379    assert!(nested.path.ends_with("mod.js"));
1380    assert!(
1381        block_on(resolver.resolve_request("npm:types@1", ResolutionMode::Import))
1382            .unwrap_err()
1383            .to_string()
1384            .contains("no executable code")
1385    );
1386}
1387
1388#[test]
1389fn resolution_is_confined_to_manifests() {
1390    let server = Registry::new();
1391    server.publish_package(
1392        "confined",
1393        "1.0.0",
1394        serde_json::json!({"exports": {".": "./mod.js", "./evil": "./node_modules/x/index.js"}}),
1395        &[
1396            ("mod.js", "export default 1;"),
1397            ("node_modules/x/index.js", "export default 2;"),
1398        ],
1399    );
1400    let dir = tempfile::tempdir().unwrap();
1401    std::fs::write(dir.path().join("local.js"), "local").unwrap();
1402    let resolver = server.resolver(dir.path());
1403    block_on(resolver.prepare(&[NpmRootRequest {
1404        request: "npm:confined@1".into(),
1405        context: BTreeMap::new(),
1406    }]))
1407    .unwrap();
1408    let root = resolve(&resolver, "npm:confined@1");
1409    assert!(
1410        resolver
1411            .resolve_from_file("../local.js", &root.path, ResolutionMode::Import)
1412            .unwrap_err()
1413            .to_string()
1414            .contains("escapes")
1415    );
1416    assert!(
1417        block_on(resolver.resolve_request("npm:confined@1/evil", ResolutionMode::Import))
1418            .unwrap_err()
1419            .to_string()
1420            .contains("escapes")
1421    );
1422    assert!(
1423        resolver
1424            .resolve_from_file("fs", &root.path, ResolutionMode::Import)
1425            .unwrap_err()
1426            .to_string()
1427            .contains("not a declared dependency")
1428    );
1429    assert!(
1430        resolver
1431            .resolve_from_file(
1432                "./mod.js",
1433                &dir.path().join("local.js"),
1434                ResolutionMode::Import
1435            )
1436            .unwrap_err()
1437            .to_string()
1438            .contains("does not own")
1439    );
1440    let unowned = dir
1441        .path()
1442        .join(".avril")
1443        .join("npm")
1444        .join("phantom")
1445        .join("x.js");
1446    assert!(resolver.is_reserved_npm_path(&unowned));
1447    assert!(
1448        resolver
1449            .resolve_from_file("./mod.js", &unowned, ResolutionMode::Import)
1450            .unwrap_err()
1451            .to_string()
1452            .contains("not owned")
1453    );
1454}
1455
1456#[test]
1457fn frozen_offline_graphs_read_from_locks_and_trees() {
1458    let server = Registry::new();
1459    server.publish_package(
1460        "fro",
1461        "1.0.0",
1462        serde_json::json!({}),
1463        &[("index.js", "export default 0;")],
1464    );
1465    let dir = tempfile::tempdir().unwrap();
1466    let resolver = server.resolver(dir.path());
1467    block_on(resolver.prepare(&[NpmRootRequest {
1468        request: "npm:fro@1".into(),
1469        context: BTreeMap::new(),
1470    }]))
1471    .unwrap();
1472    resolver.store().save().unwrap();
1473    let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
1474    // Download caches go away; the tree and lock stay.
1475    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap();
1476    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
1477    let warmed = server.requests.lock().unwrap().len();
1478    let mut offline = server.options(dir.path());
1479    offline.offline = true;
1480    offline.frozen = true;
1481    let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), offline).unwrap());
1482    let cold = NpmResolver::new(store);
1483    block_on(cold.prepare(&[NpmRootRequest {
1484        request: "npm:fro@1".into(),
1485        context: BTreeMap::new(),
1486    }]))
1487    .unwrap();
1488    let module = resolve(&cold, "npm:fro@1");
1489    assert_eq!(cold.npm_source(&module.path).unwrap(), "export default 0;");
1490    assert_eq!(server.requests.lock().unwrap().len(), warmed);
1491    cold.store().save().unwrap();
1492    assert_eq!(
1493        std::fs::read(dir.path().join("avril.lock")).unwrap(),
1494        lock_before
1495    );
1496}
1497
1498#[test]
1499fn scoped_instances_and_sibling_prefixes_have_component_ownership() {
1500    let server = Registry::new();
1501    server.publish_package("@scope/pkg", "1.0.0", serde_json::json!({}), &[]);
1502    server.publish_package("@scope/pkg-sibling", "1.0.0", serde_json::json!({}), &[]);
1503    let dir = tempfile::tempdir().unwrap();
1504    let resolver = server.resolver(dir.path());
1505    let scoped = resolve(&resolver, "npm:@scope/pkg@1");
1506    let sibling = resolve(&resolver, "npm:@scope/pkg-sibling@1");
1507
1508    assert_eq!(
1509        resolver
1510            .resolve_from_file("./index.js", &scoped.path, ResolutionMode::Import)
1511            .unwrap()
1512            .instance,
1513        scoped.instance
1514    );
1515    assert_eq!(
1516        resolver
1517            .resolve_from_file("./index.js", &sibling.path, ResolutionMode::Import)
1518            .unwrap()
1519            .instance,
1520        sibling.instance
1521    );
1522    let lookalike = scoped
1523        .path
1524        .parent()
1525        .unwrap()
1526        .with_file_name("1.0.0-sibling")
1527        .join("index.js");
1528    assert!(resolver.is_reserved_npm_path(&lookalike));
1529    assert!(
1530        resolver
1531            .resolve_from_file("./index.js", &lookalike, ResolutionMode::Import)
1532            .unwrap_err()
1533            .to_string()
1534            .contains("not owned")
1535    );
1536}
1537
1538#[test]
1539fn ownership_misses_are_not_cached_before_late_preparation() {
1540    let server = Registry::new();
1541    server.publish_package("late-owner", "1.0.0", serde_json::json!({}), &[]);
1542    let dir = tempfile::tempdir().unwrap();
1543    let writer = server.resolver(dir.path());
1544    let root = resolve(&writer, "npm:late-owner@1");
1545    writer.store().save().unwrap();
1546
1547    let resolver = server.resolver(dir.path());
1548    assert!(
1549        resolver
1550            .resolve_from_file("./index.js", &root.path, ResolutionMode::Import)
1551            .unwrap_err()
1552            .to_string()
1553            .contains("not owned")
1554    );
1555    block_on(resolver.prepare(&[NpmRootRequest {
1556        request: "npm:late-owner@1".into(),
1557        context: BTreeMap::new(),
1558    }]))
1559    .unwrap();
1560    assert_eq!(
1561        resolver
1562            .resolve_from_file("./index.js", &root.path, ResolutionMode::Import)
1563            .unwrap()
1564            .instance,
1565        root.instance
1566    );
1567}
1568
1569#[test]
1570fn late_roots_never_rebind_evaluated_instances() {
1571    let server = Registry::new();
1572    server.publish_package("one", "1.0.0", serde_json::json!({}), &[]);
1573    server.publish_package("two", "1.0.0", serde_json::json!({}), &[]);
1574    let dir = tempfile::tempdir().unwrap();
1575    let resolver = server.resolver(dir.path());
1576    block_on(resolver.prepare(&[NpmRootRequest {
1577        request: "npm:one@1".into(),
1578        context: BTreeMap::new(),
1579    }]))
1580    .unwrap();
1581    let first = resolve(&resolver, "npm:one@1");
1582    block_on(resolver.prepare(&[
1583        NpmRootRequest {
1584            request: "npm:one@1".into(),
1585            context: BTreeMap::new(),
1586        },
1587        NpmRootRequest {
1588            request: "npm:two@1".into(),
1589            context: BTreeMap::new(),
1590        },
1591    ]))
1592    .unwrap();
1593    assert_eq!(resolve(&resolver, "npm:one@1").instance, first.instance);
1594    assert_eq!(
1595        resolver
1596            .resolve_from_file("./index.js", &first.path, ResolutionMode::Import)
1597            .unwrap()
1598            .instance,
1599        first.instance
1600    );
1601}
1602
1603#[test]
1604fn incompatible_existing_peers_are_explicit_errors() {
1605    let server = Registry::new();
1606    server.publish_package("react", "17.0.0", serde_json::json!({}), &[]);
1607    server.publish_package(
1608        "plugin",
1609        "1.0.0",
1610        serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}),
1611        &[],
1612    );
1613    let dir = tempfile::tempdir().unwrap();
1614    let resolver = server.resolver(dir.path());
1615    let context = BTreeMap::from([("react".into(), identity(&server, "react", "17.0.0"))]);
1616    assert!(
1617        block_on(resolver.prepare(&[NpmRootRequest {
1618            request: "npm:plugin@1".into(),
1619            context,
1620        }]))
1621        .unwrap_err()
1622        .to_string()
1623        .contains("wants ^18.0.0")
1624    );
1625}
1626
1627#[test]
1628fn platform_gating_applies_to_regular_but_skips_optional() {
1629    let server = Registry::new();
1630    server.publish_package(
1631        "alien",
1632        "1.0.0",
1633        serde_json::json!({"os": ["fictitious-os-xyz"]}),
1634        &[],
1635    );
1636    server.publish_package(
1637        "host",
1638        "1.0.0",
1639        serde_json::json!({"optionalDependencies": {"alien": "^1.0.0"}}),
1640        &[],
1641    );
1642    let dir = tempfile::tempdir().unwrap();
1643    let resolver = server.resolver(dir.path());
1644    assert!(
1645        block_on(resolver.prepare(&[NpmRootRequest {
1646            request: "npm:alien@1".into(),
1647            context: BTreeMap::new(),
1648        }]))
1649        .unwrap_err()
1650        .to_string()
1651        .contains("not supported on this platform")
1652    );
1653    block_on(resolver.prepare(&[NpmRootRequest {
1654        request: "npm:host@1".into(),
1655        context: BTreeMap::new(),
1656    }]))
1657    .unwrap();
1658    let host = resolve(&resolver, "npm:host@1");
1659    assert!(
1660        resolver
1661            .resolve_from_file("alien", &host.path, ResolutionMode::Import)
1662            .unwrap_err()
1663            .to_string()
1664            .contains("not a declared dependency")
1665    );
1666}
1667
1668#[test]
1669fn dev_dependencies_are_ignored_and_aliases_resolve() {
1670    let server = Registry::new();
1671    server.publish_package("other", "2.0.0", serde_json::json!({}), &[]);
1672    server.publish_package(
1673        "app",
1674        "1.0.0",
1675        serde_json::json!({
1676            "dependencies": {"real": "npm:other@^2.0.0"},
1677            "devDependencies": {"ghost-dev": "^9.9.9"},
1678        }),
1679        &[],
1680    );
1681    let dir = tempfile::tempdir().unwrap();
1682    let resolver = server.resolver(dir.path());
1683    block_on(resolver.prepare(&[NpmRootRequest {
1684        request: "npm:app@1".into(),
1685        context: BTreeMap::new(),
1686    }]))
1687    .unwrap();
1688    let app = resolve(&resolver, "npm:app@1");
1689    let real = resolver
1690        .resolve_from_file("real", &app.path, ResolutionMode::Import)
1691        .unwrap();
1692    assert!(real.path.to_string_lossy().contains("/other/2.0.0/"));
1693}
1694
1695#[test]
1696fn unsupported_dependency_protocols_are_clear_errors() {
1697    let server = Registry::new();
1698    server.publish_package(
1699        "weird",
1700        "1.0.0",
1701        serde_json::json!({"dependencies": {"local": "file:../other"}}),
1702        &[],
1703    );
1704    let dir = tempfile::tempdir().unwrap();
1705    let resolver = server.resolver(dir.path());
1706    assert!(
1707        block_on(resolver.prepare(&[NpmRootRequest {
1708            request: "npm:weird@1".into(),
1709            context: BTreeMap::new(),
1710        }]))
1711        .unwrap_err()
1712        .to_string()
1713        .contains("unsupported protocol")
1714    );
1715}
1716
1717#[test]
1718fn frozen_graphs_validate_completeness() {
1719    let server = Registry::new();
1720    server.publish_package("whole", "1.0.0", serde_json::json!({}), &[]);
1721    let dir = tempfile::tempdir().unwrap();
1722    let resolver = server.resolver(dir.path());
1723    block_on(resolver.prepare(&[NpmRootRequest {
1724        request: "npm:whole@1".into(),
1725        context: BTreeMap::new(),
1726    }]))
1727    .unwrap();
1728    resolver.store().save().unwrap();
1729    let mut lock: serde_json::Value = fixture::read_lock(dir.path());
1730    let instances = lock["npm"]["instances"].as_object_mut().unwrap();
1731    let victim = instances.keys().next().unwrap().clone();
1732    instances.remove(&victim);
1733    std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap();
1734    // The pruned lock references an instance that no longer exists.
1735    let mut frozen = server.options(dir.path());
1736    frozen.frozen = true;
1737    assert!(
1738        PackageStore::new(fixture::runtime_handle(), frozen)
1739            .map(|_| ())
1740            .unwrap_err()
1741            .to_string()
1742            .contains("missing npm instance")
1743    );
1744}
1745
1746#[test]
1747fn update_reselects_new_versions() {
1748    let server = Registry::new();
1749    server.publish_package("up", "1.0.0", serde_json::json!({}), &[]);
1750    let dir = tempfile::tempdir().unwrap();
1751    let resolver = server.resolver(dir.path());
1752    block_on(resolver.prepare(&[NpmRootRequest {
1753        request: "npm:up".into(),
1754        context: BTreeMap::new(),
1755    }]))
1756    .unwrap();
1757    assert!(
1758        resolve(&resolver, "npm:up")
1759            .path
1760            .to_string_lossy()
1761            .contains("/up/1.0.0/")
1762    );
1763    resolver.store().save().unwrap();
1764    server.publish_package("up", "2.0.0", serde_json::json!({}), &[]);
1765    let mut updating = server.options(dir.path());
1766    updating.update = true;
1767    let store =
1768        std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), updating).unwrap());
1769    let resolver = NpmResolver::new(store);
1770    block_on(resolver.prepare(&[NpmRootRequest {
1771        request: "npm:up".into(),
1772        context: BTreeMap::new(),
1773    }]))
1774    .unwrap();
1775    assert!(
1776        resolve(&resolver, "npm:up")
1777            .path
1778            .to_string_lossy()
1779            .contains("/up/2.0.0/")
1780    );
1781}
1782
1783#[test]
1784fn nested_relative_imports_resolve_from_the_referrer() {
1785    let server = Registry::new();
1786    server.publish_package(
1787        "deep",
1788        "1.0.0",
1789        serde_json::json!({"type": "module"}),
1790        &[
1791            ("index.js", "export * from './lib/mod.js';"),
1792            (
1793                "lib/mod.js",
1794                "export * from './sibling.js'; export * from '../shared.js';",
1795            ),
1796            ("lib/sibling.js", "export default 1;"),
1797            ("shared.js", "export default 2;"),
1798        ],
1799    );
1800    let dir = tempfile::tempdir().unwrap();
1801    let resolver = server.resolver(dir.path());
1802    block_on(resolver.prepare(&[NpmRootRequest {
1803        request: "npm:deep@1".into(),
1804        context: BTreeMap::new(),
1805    }]))
1806    .unwrap();
1807    let entry = resolve(&resolver, "npm:deep@1/lib/mod.js");
1808    assert!(entry.path.ends_with("lib/mod.js"));
1809    let sibling = resolver
1810        .resolve_from_file("./sibling.js", &entry.path, ResolutionMode::Import)
1811        .unwrap();
1812    assert!(sibling.path.ends_with("lib/sibling.js"));
1813    let shared = resolver
1814        .resolve_from_file("../shared.js", &entry.path, ResolutionMode::Import)
1815        .unwrap();
1816    assert!(shared.path.ends_with("shared.js"));
1817    assert!(
1818        resolver
1819            .resolve_from_file("../../escape.js", &entry.path, ResolutionMode::Import)
1820            .unwrap_err()
1821            .to_string()
1822            .contains("escapes")
1823    );
1824    assert!(
1825        resolver
1826            .resolve_from_file("..%2fshared.js", &entry.path, ResolutionMode::Import)
1827            .unwrap_err()
1828            .to_string()
1829            .contains("Invalid")
1830    );
1831}
1832
1833#[test]
1834fn external_imports_keep_their_subpaths() {
1835    let server = Registry::new();
1836    server.publish_package(
1837        "other",
1838        "1.0.0",
1839        serde_json::json!({"exports": {".": "./mod.js", "./util": "./util.js"}}),
1840        &[
1841            ("mod.js", "export default 1;"),
1842            ("util.js", "export default 2;"),
1843        ],
1844    );
1845    server.publish_package(
1846        "user",
1847        "1.0.0",
1848        serde_json::json!({
1849            "dependencies": {"other": "^1.0.0"},
1850            "imports": {"#tool": "other/util"},
1851        }),
1852        &[("index.js", "import '#tool';")],
1853    );
1854    let dir = tempfile::tempdir().unwrap();
1855    let resolver = server.resolver(dir.path());
1856    block_on(resolver.prepare(&[NpmRootRequest {
1857        request: "npm:user@1".into(),
1858        context: BTreeMap::new(),
1859    }]))
1860    .unwrap();
1861    let entry = resolve(&resolver, "npm:user@1");
1862    let tool = resolver
1863        .resolve_from_file("#tool", &entry.path, ResolutionMode::Import)
1864        .unwrap();
1865    assert!(tool.path.ends_with("util.js"));
1866    assert!(tool.path.to_string_lossy().contains("/other/1.0.0/"));
1867}
1868
1869#[test]
1870fn conditional_exports_never_serve_unlisted_subpaths() {
1871    let server = Registry::new();
1872    server.publish_package(
1873        "guarded",
1874        "1.0.0",
1875        serde_json::json!({"exports": {"import": "./main.js"}}),
1876        &[
1877            ("main.js", "export default 1;"),
1878            ("private.js", "export default 2;"),
1879        ],
1880    );
1881    let dir = tempfile::tempdir().unwrap();
1882    let resolver = server.resolver(dir.path());
1883    block_on(resolver.prepare(&[NpmRootRequest {
1884        request: "npm:guarded@1".into(),
1885        context: BTreeMap::new(),
1886    }]))
1887    .unwrap();
1888    assert!(
1889        resolve(&resolver, "npm:guarded@1")
1890            .path
1891            .ends_with("main.js")
1892    );
1893    assert!(
1894        block_on(resolver.resolve_request("npm:guarded@1/private", ResolutionMode::Import))
1895            .unwrap_err()
1896            .to_string()
1897            .contains("no export")
1898    );
1899}
1900
1901#[test]
1902fn concurrent_prepares_share_work_without_deadlock() {
1903    let server = Registry::new();
1904    server.publish_package("slow", "1.0.0", serde_json::json!({}), &[]);
1905    server.publish_package(
1906        "top",
1907        "1.0.0",
1908        serde_json::json!({"dependencies": {"slow": "^1.0.0"}}),
1909        &[],
1910    );
1911    let dir = tempfile::tempdir().unwrap();
1912    let resolver = server.resolver(dir.path());
1913    let root = |request: &str| NpmRootRequest {
1914        request: request.into(),
1915        context: BTreeMap::new(),
1916    };
1917    // Bounded: a deadlock would trip the timeout instead of hanging the suite.
1918    let (sender, receiver) = std::sync::mpsc::channel();
1919    std::thread::scope(|scope| {
1920        scope.spawn(|| {
1921            let pair = block_on(futures::future::join(
1922                resolver.prepare(&[root("npm:top@1")]),
1923                resolver.prepare(&[root("npm:slow@1")]),
1924            ));
1925            let _ = sender.send(pair);
1926        });
1927        let (first, second) = receiver
1928            .recv_timeout(std::time::Duration::from_secs(60))
1929            .unwrap();
1930        first.unwrap();
1931        second.unwrap();
1932    });
1933    for request in ["npm:top@1", "npm:slow@1"] {
1934        let module = resolve(&resolver, request);
1935        assert!(resolver.npm_source(&module.path).is_ok());
1936    }
1937}
1938
1939#[test]
1940fn peerless_wrappers_fork_across_host_contexts() {
1941    let server = Registry::new();
1942    server.publish_package("react", "17.0.0", serde_json::json!({}), &[]);
1943    server.publish_package("react", "18.2.0", serde_json::json!({}), &[]);
1944    server.publish_package(
1945        "plugin",
1946        "1.0.0",
1947        serde_json::json!({"peerDependencies": {"react": "*"}}),
1948        &[],
1949    );
1950    server.publish_package(
1951        "wrapper",
1952        "1.0.0",
1953        serde_json::json!({"dependencies": {"plugin": "1.0.0"}}),
1954        &[],
1955    );
1956    server.publish_package(
1957        "h17",
1958        "1.0.0",
1959        serde_json::json!({"dependencies": {"react": "17.0.0", "wrapper": "1.0.0"}}),
1960        &[],
1961    );
1962    server.publish_package(
1963        "h18",
1964        "1.0.0",
1965        serde_json::json!({"dependencies": {"react": "18.2.0", "wrapper": "1.0.0"}}),
1966        &[],
1967    );
1968    let dir = tempfile::tempdir().unwrap();
1969    let resolver = server.resolver(dir.path());
1970    block_on(resolver.prepare(&[
1971        NpmRootRequest {
1972            request: "npm:h17@1".into(),
1973            context: BTreeMap::new(),
1974        },
1975        NpmRootRequest {
1976            request: "npm:h18@1".into(),
1977            context: BTreeMap::new(),
1978        },
1979    ]))
1980    .unwrap();
1981    let h17 = resolve(&resolver, "npm:h17@1");
1982    let h18 = resolve(&resolver, "npm:h18@1");
1983    let w17 = resolver
1984        .resolve_from_file("wrapper", &h17.path, ResolutionMode::Import)
1985        .unwrap();
1986    let w18 = resolver
1987        .resolve_from_file("wrapper", &h18.path, ResolutionMode::Import)
1988        .unwrap();
1989    assert_ne!(
1990        w17.instance, w18.instance,
1991        "wrapper contexts must not share"
1992    );
1993    assert_ne!(w17.path, w18.path);
1994}
1995
1996#[test]
1997fn peer_cycles_terminate() {
1998    let server = Registry::new();
1999    server.publish_package(
2000        "pa",
2001        "1.0.0",
2002        serde_json::json!({"peerDependencies": {"pb": "^1.0.0"}}),
2003        &[],
2004    );
2005    server.publish_package(
2006        "pb",
2007        "1.0.0",
2008        serde_json::json!({"peerDependencies": {"pa": "^1.0.0"}}),
2009        &[],
2010    );
2011    server.publish_package(
2012        "top",
2013        "1.0.0",
2014        serde_json::json!({"dependencies": {"pa": "1.0.0", "pb": "1.0.0"}}),
2015        &[],
2016    );
2017    let dir = tempfile::tempdir().unwrap();
2018    let resolver = server.resolver(dir.path());
2019    block_on(resolver.prepare(&[NpmRootRequest {
2020        request: "npm:top@1".into(),
2021        context: BTreeMap::new(),
2022    }]))
2023    .unwrap();
2024    let top = resolve(&resolver, "npm:top@1");
2025    let pa = resolver
2026        .resolve_from_file("pa", &top.path, ResolutionMode::Import)
2027        .unwrap();
2028    let pb = resolver
2029        .resolve_from_file("pb", &top.path, ResolutionMode::Import)
2030        .unwrap();
2031    assert_ne!(pa.path, pb.path);
2032    assert!(resolver.npm_source(&pa.path).is_ok());
2033    assert!(resolver.npm_source(&pb.path).is_ok());
2034}
2035
2036#[test]
2037fn missing_deep_frozen_instances_are_errors() {
2038    let server = Registry::new();
2039    server.publish_package(
2040        "leaf",
2041        "1.0.0",
2042        serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}),
2043        &[],
2044    );
2045    server.publish_package("react", "18.2.0", serde_json::json!({}), &[]);
2046    server.publish_package(
2047        "mid",
2048        "1.0.0",
2049        serde_json::json!({"dependencies": {"leaf": "^1.0.0", "react": "18.2.0"}}),
2050        &[],
2051    );
2052    let dir = tempfile::tempdir().unwrap();
2053    let resolver = server.resolver(dir.path());
2054    block_on(resolver.prepare(&[NpmRootRequest {
2055        request: "npm:mid@1".into(),
2056        context: BTreeMap::new(),
2057    }]))
2058    .unwrap();
2059    resolver.store().save().unwrap();
2060    let mut lock: serde_json::Value = fixture::read_lock(dir.path());
2061    let root_targets: Vec<String> = lock["npm"]["roots"]
2062        .as_object()
2063        .unwrap()
2064        .values()
2065        .map(|value| value.as_str().unwrap().to_owned())
2066        .collect();
2067    let victim = lock["npm"]["instances"]
2068        .as_object()
2069        .unwrap()
2070        .keys()
2071        .find(|key| !root_targets.contains(key))
2072        .cloned()
2073        .unwrap();
2074    lock["npm"]["instances"]
2075        .as_object_mut()
2076        .unwrap()
2077        .remove(&victim);
2078    std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap();
2079    // Dangling instance edges are rejected when the frozen lock opens, so a
2080    // frozen prepare can never run on a partial graph.
2081    let mut frozen = server.options(dir.path());
2082    frozen.frozen = true;
2083    assert!(
2084        PackageStore::new(fixture::runtime_handle(), frozen)
2085            .map(|_| ())
2086            .unwrap_err()
2087            .to_string()
2088            .contains("missing npm instance")
2089    );
2090}
2091
2092#[test]
2093fn updated_dependency_versions_reselect_without_conflicts() {
2094    let server = Registry::new();
2095    server.publish_package("dep", "1.0.0", serde_json::json!({}), &[]);
2096    server.publish_package(
2097        "app",
2098        "1.0.0",
2099        serde_json::json!({"dependencies": {"dep": ">=1.0.0 <3.0.0"}}),
2100        &[],
2101    );
2102    let dir = tempfile::tempdir().unwrap();
2103    let resolver = server.resolver(dir.path());
2104    block_on(resolver.prepare(&[NpmRootRequest {
2105        request: "npm:app@1".into(),
2106        context: BTreeMap::new(),
2107    }]))
2108    .unwrap();
2109    let before = resolve(&resolver, "npm:app@1");
2110    resolver.store().save().unwrap();
2111    server.publish_package("dep", "2.0.0", serde_json::json!({}), &[]);
2112    let mut updating = server.options(dir.path());
2113    updating.update = true;
2114    let store =
2115        std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), updating).unwrap());
2116    let resolver = NpmResolver::new(store);
2117    block_on(resolver.prepare(&[NpmRootRequest {
2118        request: "npm:app@1".into(),
2119        context: BTreeMap::new(),
2120    }]))
2121    .unwrap();
2122    let after = resolve(&resolver, "npm:app@1");
2123    assert_ne!(before.instance, after.instance);
2124    resolver.store().save().unwrap();
2125}
2126
2127#[test]
2128fn preselected_providers_win_regardless_of_root_order() {
2129    let server = Registry::new();
2130    server.publish_package("host", "1.0.0", serde_json::json!({}), &[]);
2131    server.publish_package("host", "1.1.0", serde_json::json!({}), &[]);
2132    server.publish_package(
2133        "plugin",
2134        "1.0.0",
2135        serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}),
2136        &[],
2137    );
2138    for order in [false, true] {
2139        let dir = tempfile::tempdir().unwrap();
2140        let resolver = server.resolver(dir.path());
2141        let mut roots = vec![
2142            NpmRootRequest {
2143                request: "npm:host@1.0.0".into(),
2144                context: BTreeMap::new(),
2145            },
2146            NpmRootRequest {
2147                request: "npm:plugin@^1".into(),
2148                context: BTreeMap::new(),
2149            },
2150        ];
2151        if order {
2152            roots.reverse();
2153        }
2154        block_on(resolver.prepare(&roots)).unwrap();
2155        let plugin = resolve(&resolver, "npm:plugin@^1");
2156        let host = resolver
2157            .resolve_from_file("host", &plugin.path, ResolutionMode::Import)
2158            .unwrap();
2159        assert!(
2160            host.path.to_string_lossy().contains("/host/1.0.0/"),
2161            "order {order}: plugin must bind the preselected host 1.0.0"
2162        );
2163    }
2164    // Without the host root the same plugin floats to latest instead.
2165    let dir = tempfile::tempdir().unwrap();
2166    let resolver = server.resolver(dir.path());
2167    block_on(resolver.prepare(&[NpmRootRequest {
2168        request: "npm:plugin@^1".into(),
2169        context: BTreeMap::new(),
2170    }]))
2171    .unwrap();
2172    let plugin = resolve(&resolver, "npm:plugin@^1");
2173    let host = resolver
2174        .resolve_from_file("host", &plugin.path, ResolutionMode::Import)
2175        .unwrap();
2176    assert!(host.path.to_string_lossy().contains("/host/1.1.0/"));
2177}
2178
2179#[test]
2180fn cyclic_identities_ignore_unrelated_roots_and_batches() {
2181    let server = Registry::new();
2182    server.publish_package(
2183        "ca",
2184        "1.0.0",
2185        serde_json::json!({"dependencies": {"cb": "1.0.0"}}),
2186        &[],
2187    );
2188    server.publish_package(
2189        "cb",
2190        "1.0.0",
2191        serde_json::json!({"dependencies": {"ca": "1.0.0"}}),
2192        &[],
2193    );
2194    server.publish_package("solo", "1.0.0", serde_json::json!({}), &[]);
2195    let dir = tempfile::tempdir().unwrap();
2196    let resolver = server.resolver(dir.path());
2197    let alone = block_on(resolver.prepare(&[NpmRootRequest {
2198        request: "npm:ca@1".into(),
2199        context: BTreeMap::new(),
2200    }]))
2201    .unwrap();
2202    let with_other = block_on(resolver.prepare(&[NpmRootRequest {
2203        request: "npm:solo@1".into(),
2204        context: BTreeMap::new(),
2205    }]))
2206    .unwrap();
2207    assert_eq!(
2208        alone.roots.values().next().unwrap(),
2209        &resolve(&resolver, "npm:ca@1").instance,
2210        "re-preparing alongside an unrelated root must not rebind"
2211    );
2212    assert!(with_other.roots.len() == 1);
2213
2214    let dir = tempfile::tempdir().unwrap();
2215    let fresh = server.resolver(dir.path());
2216    block_on(fresh.prepare(&[
2217        NpmRootRequest {
2218            request: "npm:solo@1".into(),
2219            context: BTreeMap::new(),
2220        },
2221        NpmRootRequest {
2222            request: "npm:ca@1".into(),
2223            context: BTreeMap::new(),
2224        },
2225    ]))
2226    .unwrap();
2227    assert_eq!(
2228        resolve(&fresh, "npm:ca@1").instance,
2229        resolve(&resolver, "npm:ca@1").instance,
2230        "cycle identities are batch independent"
2231    );
2232}
2233
2234#[test]
2235fn same_request_in_two_contexts_resolves_through_handles() {
2236    let server = Registry::new();
2237    server.publish_package("host", "1.0.0", serde_json::json!({}), &[]);
2238    server.publish_package("host", "2.0.0", serde_json::json!({}), &[]);
2239    server.publish_package(
2240        "plugin",
2241        "1.0.0",
2242        serde_json::json!({"peerDependencies": {"host": "*"}}),
2243        &[],
2244    );
2245    let dir = tempfile::tempdir().unwrap();
2246    let resolver = server.resolver(dir.path());
2247    let handle_a = block_on(resolver.prepare(&[NpmRootRequest {
2248        request: "npm:plugin@1".into(),
2249        context: BTreeMap::from([("host".into(), identity(&server, "host", "1.0.0"))]),
2250    }]))
2251    .unwrap();
2252    let handle_b = block_on(resolver.prepare(&[NpmRootRequest {
2253        request: "npm:plugin@1".into(),
2254        context: BTreeMap::from([("host".into(), identity(&server, "host", "2.0.0"))]),
2255    }]))
2256    .unwrap();
2257    let a = resolver
2258        .resolve_in(&handle_a, "npm:plugin@1", ResolutionMode::Import)
2259        .unwrap();
2260    let b = resolver
2261        .resolve_in(&handle_b, "npm:plugin@1", ResolutionMode::Import)
2262        .unwrap();
2263    assert_ne!(a.instance, b.instance);
2264    assert_ne!(a.path, b.path);
2265    assert_eq!(
2266        resolver
2267            .resolve_in(&handle_a, "npm:plugin@1", ResolutionMode::Import)
2268            .unwrap()
2269            .path,
2270        a.path
2271    );
2272    // A bare request across both contexts stays ambiguous instead of
2273    // silently picking one.
2274    assert!(
2275        block_on(resolver.resolve_request("npm:plugin@1", ResolutionMode::Import))
2276            .unwrap_err()
2277            .to_string()
2278            .contains("several peer contexts")
2279    );
2280}
2281
2282#[test]
2283fn missing_roots_rebuild_from_specifier_pins() {
2284    let server = Registry::new();
2285    server.publish_package(
2286        "re",
2287        "3.0.0",
2288        serde_json::json!({}),
2289        &[("index.js", "export default 0;")],
2290    );
2291    let dir = tempfile::tempdir().unwrap();
2292    let resolver = server.resolver(dir.path());
2293    block_on(resolver.prepare(&[NpmRootRequest {
2294        request: "npm:re@3".into(),
2295        context: BTreeMap::new(),
2296    }]))
2297    .unwrap();
2298    resolver.store().save().unwrap();
2299    let mut lock: serde_json::Value = fixture::read_lock(dir.path());
2300    lock["npm"]["roots"].as_object_mut().unwrap().clear();
2301    lock["npm"]["instances"].as_object_mut().unwrap().clear();
2302    std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap();
2303    // Specifier and artifact pins survive; the root entry does not.
2304    let store = std::sync::Arc::new(
2305        PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(),
2306    );
2307    let rebuilt = NpmResolver::new(store);
2308    block_on(rebuilt.prepare(&[NpmRootRequest {
2309        request: "npm:re@3".into(),
2310        context: BTreeMap::new(),
2311    }]))
2312    .unwrap();
2313    let module = resolve(&rebuilt, "npm:re@3");
2314    assert_eq!(
2315        rebuilt.npm_source(&module.path).unwrap(),
2316        "export default 0;"
2317    );
2318}
2319
2320#[test]
2321fn new_contexts_cannot_overwrite_locked_instance_bindings() {
2322    let server = Registry::new();
2323    server.publish_package("left", "1.0.0", serde_json::json!({}), &[]);
2324    server.publish_package("right", "1.0.0", serde_json::json!({}), &[]);
2325    server.publish_package(
2326        "top",
2327        "1.0.0",
2328        serde_json::json!({"dependencies": {"left": "1.0.0"}}),
2329        &[],
2330    );
2331    let dir = tempfile::tempdir().unwrap();
2332    let resolver = server.resolver(dir.path());
2333    block_on(resolver.prepare(&[
2334        NpmRootRequest {
2335            request: "npm:top@1".into(),
2336            context: BTreeMap::new(),
2337        },
2338        NpmRootRequest {
2339            request: "npm:right@1".into(),
2340            context: BTreeMap::new(),
2341        },
2342    ]))
2343    .unwrap();
2344    resolver.store().save().unwrap();
2345    let mut lock: serde_json::Value = fixture::read_lock(dir.path());
2346    let right = identity(&server, "right", "1.0.0");
2347    let instances = lock["npm"]["instances"].as_object_mut().unwrap();
2348    let top_key = instances
2349        .iter()
2350        .find(|(_, pin)| {
2351            pin["package"]
2352                .as_str()
2353                .is_some_and(|id| id.contains("|top|"))
2354        })
2355        .map(|(key, _)| key.clone())
2356        .unwrap();
2357    instances[top_key.as_str()]["deps"]["left"] = serde_json::Value::String(right);
2358    std::fs::write(dir.path().join("avril.lock"), format!("{lock}\n")).unwrap();
2359    let store = std::sync::Arc::new(
2360        PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap(),
2361    );
2362    let tampered = NpmResolver::new(store);
2363    // This root-only context was never recorded. Its new resolution must not
2364    // overwrite an instance already assigned different authoritative bindings.
2365    assert!(
2366        block_on(tampered.prepare(&[NpmRootRequest {
2367            request: "npm:top@1".into(),
2368            context: BTreeMap::new(),
2369        }]))
2370        .unwrap_err()
2371        .to_string()
2372        .contains("changed")
2373    );
2374}
2375
2376#[test]
2377fn failed_prepares_leave_retryable_state() {
2378    let server = Registry::new();
2379    server.publish_package(
2380        "flaky",
2381        "1.0.0",
2382        serde_json::json!({"dependencies": {"ghost": "^9.9.9"}}),
2383        &[],
2384    );
2385    let dir = tempfile::tempdir().unwrap();
2386    let resolver = server.resolver(dir.path());
2387    assert!(
2388        block_on(resolver.prepare(&[NpmRootRequest {
2389            request: "npm:flaky@1".into(),
2390            context: BTreeMap::new(),
2391        }]))
2392        .is_err()
2393    );
2394    assert!(resolver.owning_instance(&dir.path().join("x.js")).is_none());
2395    server.publish_package("ghost", "9.9.9", serde_json::json!({}), &[]);
2396    block_on(resolver.prepare(&[NpmRootRequest {
2397        request: "npm:flaky@1".into(),
2398        context: BTreeMap::new(),
2399    }]))
2400    .unwrap();
2401    let module = resolve(&resolver, "npm:flaky@1");
2402    assert!(resolver.npm_source(&module.path).is_ok());
2403}
2404
2405#[test]
2406fn malformed_peer_ranges_are_errors() {
2407    let server = Registry::new();
2408    server.publish_package(
2409        "bad",
2410        "1.0.0",
2411        serde_json::json!({"peerDependencies": {"ghost": "^^invalid^^"}}),
2412        &[],
2413    );
2414    let dir = tempfile::tempdir().unwrap();
2415    let resolver = server.resolver(dir.path());
2416    assert!(
2417        block_on(resolver.prepare(&[NpmRootRequest {
2418            request: "npm:bad@1".into(),
2419            context: BTreeMap::new(),
2420        }]))
2421        .unwrap_err()
2422        .to_string()
2423        .contains("Invalid")
2424    );
2425}
2426
2427#[test]
2428fn frozen_save_graph_writes_nothing() {
2429    let server = Registry::new();
2430    server.publish_package("sg", "1.0.0", serde_json::json!({}), &[]);
2431    let dir = tempfile::tempdir().unwrap();
2432    let resolver = server.resolver(dir.path());
2433    block_on(resolver.prepare(&[NpmRootRequest {
2434        request: "npm:sg@1".into(),
2435        context: BTreeMap::new(),
2436    }]))
2437    .unwrap();
2438    resolver.store().save().unwrap();
2439    let lock_before = std::fs::read(dir.path().join("avril.lock")).unwrap();
2440    let mut frozen = server.options(dir.path());
2441    frozen.frozen = true;
2442    let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), frozen).unwrap());
2443    store
2444        .save_graph(
2445            &std::collections::BTreeSet::from(["npm:sg@1".to_owned()]),
2446            &std::collections::HashSet::new(),
2447        )
2448        .unwrap();
2449    assert_eq!(
2450        std::fs::read(dir.path().join("avril.lock")).unwrap(),
2451        lock_before
2452    );
2453    let _ = resolver;
2454}
2455
2456#[test]
2457fn frozen_new_roots_are_missing_without_network() {
2458    let server = Registry::new();
2459    server.publish_package("known", "1.0.0", serde_json::json!({}), &[]);
2460    server.publish_package("fresh", "1.0.0", serde_json::json!({}), &[]);
2461    let dir = tempfile::tempdir().unwrap();
2462    let resolver = server.resolver(dir.path());
2463    block_on(resolver.prepare(&[NpmRootRequest {
2464        request: "npm:known@1".into(),
2465        context: BTreeMap::new(),
2466    }]))
2467    .unwrap();
2468    resolver.store().save().unwrap();
2469    let requests = server.requests.lock().unwrap().len();
2470    let mut frozen = server.options(dir.path());
2471    frozen.frozen = true;
2472    let store = std::sync::Arc::new(PackageStore::new(fixture::runtime_handle(), frozen).unwrap());
2473    let frozen_resolver = NpmResolver::new(store);
2474    assert!(
2475        block_on(frozen_resolver.prepare(&[NpmRootRequest {
2476            request: "npm:fresh@1".into(),
2477            context: BTreeMap::new(),
2478        }]))
2479        .unwrap_err()
2480        .to_string()
2481        .contains("Frozen lockfile is missing")
2482    );
2483    assert_eq!(server.requests.lock().unwrap().len(), requests);
2484}
2485
2486#[test]
2487fn failed_contexts_leave_no_lock_pollution() {
2488    let server = Registry::new();
2489    server.publish_package("host", "1.0.0", serde_json::json!({}), &[]);
2490    server.publish_package(
2491        "plugin",
2492        "1.0.0",
2493        serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}),
2494        &[],
2495    );
2496    let dir = tempfile::tempdir().unwrap();
2497    let resolver = server.resolver(dir.path());
2498    let bad = BTreeMap::from([("host".into(), "http://127.0.0.1:9/|host|9.9.9".into())]);
2499    assert!(
2500        block_on(resolver.prepare(&[NpmRootRequest {
2501            request: "npm:plugin@1".into(),
2502            context: bad.clone(),
2503        }]))
2504        .is_err()
2505    );
2506    let good = BTreeMap::from([("host".into(), identity(&server, "host", "1.0.0"))]);
2507    block_on(resolver.prepare(&[NpmRootRequest {
2508        request: "npm:plugin@1".into(),
2509        context: good,
2510    }]))
2511    .unwrap();
2512    resolver.store().save().unwrap();
2513    let lock = std::fs::read_to_string(dir.path().join("avril.lock")).unwrap();
2514    assert!(!lock.contains("9.9.9"), "{lock}");
2515    assert!(!lock.contains("127.0.0.1:9"), "{lock}");
2516}
2517
2518#[test]
2519fn same_plugin_shares_host_across_matching_contexts() {
2520    let server = Registry::new();
2521    server.publish_package("host", "1.0.0", serde_json::json!({}), &[]);
2522    server.publish_package(
2523        "plugin",
2524        "1.0.0",
2525        serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}),
2526        &[],
2527    );
2528    let dir = tempfile::tempdir().unwrap();
2529    let resolver = server.resolver(dir.path());
2530    let context = BTreeMap::from([("host".into(), identity(&server, "host", "1.0.0"))]);
2531    let first = block_on(resolver.prepare(&[NpmRootRequest {
2532        request: "npm:plugin@1".into(),
2533        context: context.clone(),
2534    }]))
2535    .unwrap();
2536    let second = block_on(resolver.prepare(&[NpmRootRequest {
2537        request: "npm:plugin@1".into(),
2538        context,
2539    }]))
2540    .unwrap();
2541    assert_eq!(first.roots, second.roots);
2542}
2543
2544#[test]
2545fn concurrent_selects_share_one_identity() {
2546    let server = Registry::new();
2547    server.publish_package("race", "1.2.0", serde_json::json!({}), &[]);
2548    let dir = tempfile::tempdir().unwrap();
2549    let resolver = server.resolver(dir.path());
2550    let (a, b) = block_on(futures::future::join(
2551        resolver.prepare(&[NpmRootRequest {
2552            request: "npm:race@^1".into(),
2553            context: BTreeMap::new(),
2554        }]),
2555        resolver.prepare(&[NpmRootRequest {
2556            request: "npm:race@~1.2.0".into(),
2557            context: BTreeMap::new(),
2558        }]),
2559    ));
2560    a.unwrap();
2561    b.unwrap();
2562    let first = resolve(&resolver, "npm:race@^1");
2563    let second = resolve(&resolver, "npm:race@~1.2.0");
2564    assert_eq!(first.path, second.path);
2565    assert_eq!(first.instance, second.instance);
2566}
2567
2568#[test]
2569fn late_dynamic_roots_reuse_prepared_namespaces() {
2570    let server = Registry::new();
2571    server.publish_package(
2572        "plug",
2573        "1.0.0",
2574        serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}),
2575        &[("index.js", "export {version} from 'host';")],
2576    );
2577    server.publish_package(
2578        "host",
2579        "1.0.0",
2580        serde_json::json!({}),
2581        &[("index.js", "export const version = 'h1';")],
2582    );
2583    server.publish_package(
2584        "extra",
2585        "1.0.0",
2586        serde_json::json!({"dependencies": {"plug": "^1.0.0"}}),
2587        &[("index.js", "export * from 'plug';")],
2588    );
2589    let dir = tempfile::tempdir().unwrap();
2590    let resolver = server.resolver(dir.path());
2591    block_on(resolver.prepare(&[NpmRootRequest {
2592        request: "npm:plug@1".into(),
2593        context: BTreeMap::new(),
2594    }]))
2595    .unwrap();
2596    let before = resolve(&resolver, "npm:plug@1");
2597    block_on(resolver.prepare(&[NpmRootRequest {
2598        request: "npm:extra@1".into(),
2599        context: BTreeMap::new(),
2600    }]))
2601    .unwrap();
2602    let after = resolve(&resolver, "npm:plug@1");
2603    assert_eq!(before.instance, after.instance);
2604    assert_eq!(before.path, after.path);
2605    let via_extra = resolve(&resolver, "npm:extra@1");
2606    assert!(resolver.npm_source(&via_extra.path).is_ok());
2607}
2608
2609fn export_requests(resolver: &NpmResolver, specifier: &str) -> Vec<String> {
2610    block_on(resolver.export_requests(specifier)).unwrap()
2611}
2612
2613#[test]
2614fn export_requests_covers_string_conditional_and_legacy_roots() {
2615    let server = Registry::new();
2616    server.publish_package(
2617        "str",
2618        "1.0.0",
2619        serde_json::json!({"exports": "./index.js"}),
2620        &[("index.js", "export default 1;")],
2621    );
2622    server.publish_package(
2623        "cond",
2624        "1.0.0",
2625        serde_json::json!({"exports": {"require": "./c.cjs", "import": "./e.mjs"}}),
2626        &[
2627            ("e.mjs", "export default 1;"),
2628            ("c.cjs", "module.exports = 1;"),
2629        ],
2630    );
2631    server.publish_package(
2632        "leg",
2633        "1.0.0",
2634        serde_json::json!({}),
2635        &[("index.js", "export default 1;")],
2636    );
2637    let dir = tempfile::tempdir().unwrap();
2638    let resolver = server.resolver(dir.path());
2639    // Range forms, so frozen runs pin the same roots the runtime prepares.
2640    assert_eq!(export_requests(&resolver, "npm:str@1"), ["npm:str@1"]);
2641    assert_eq!(export_requests(&resolver, "npm:cond@1"), ["npm:cond@1"]);
2642    assert_eq!(export_requests(&resolver, "npm:leg@1"), ["npm:leg@1"]);
2643    // The root selects the ESM branch, never the CommonJS one.
2644    let root = resolve(&resolver, "npm:cond@1");
2645    assert!(root.path.to_string_lossy().ends_with("e.mjs"));
2646}
2647
2648#[test]
2649fn export_requests_inverts_wildcards_to_public_subpaths() {
2650    let server = Registry::new();
2651    server.publish_package(
2652        "wild",
2653        "1.0.0",
2654        serde_json::json!({"exports": {
2655            ".": "./index.js",
2656            "./feature/*": "./dist/*.js",
2657            "./blocked": null,
2658            "./cjs": "./c.cjs",
2659        }}),
2660        &[
2661            ("index.js", "export default 0;"),
2662            ("dist/foo.js", "export default 1;"),
2663            ("dist/bar.js", "export default 2;"),
2664            ("feature/foo.js", "export default 'private';"),
2665            ("c.cjs", "module.exports = 3;"),
2666        ],
2667    );
2668    let dir = tempfile::tempdir().unwrap();
2669    let resolver = server.resolver(dir.path());
2670    let requests = export_requests(&resolver, "npm:wild@1");
2671    // CommonJS is warmable now that ESM wrappers support it.
2672    assert_eq!(
2673        requests,
2674        [
2675            "npm:wild@1",
2676            "npm:wild@1/cjs",
2677            "npm:wild@1/feature/bar",
2678            "npm:wild@1/feature/foo"
2679        ]
2680    );
2681    // The public subpath resolves through the pattern, not the private file.
2682    let feature =
2683        block_on(resolver.resolve_request("npm:wild@1/feature/foo", ResolutionMode::Import))
2684            .unwrap();
2685    assert!(feature.path.to_string_lossy().ends_with("dist/foo.js"));
2686}
2687
2688#[test]
2689fn export_requests_respects_scopes_and_condition_arrays() {
2690    let server = Registry::new();
2691    server.publish_package(
2692        "scoped",
2693        "1.0.0",
2694        serde_json::json!({"exports": {
2695            ".": "./index.js",
2696            "./sub": "./sub.js",
2697            "./sub/deep": {"import": [null, "./deep.mjs"], "default": "./deep.cjs"},
2698            "./other": "./other.js",
2699        }}),
2700        &[
2701            ("index.js", "export default 0;"),
2702            ("sub.js", "export default 1;"),
2703            ("deep.mjs", "export default 2;"),
2704            ("other.js", "export default 3;"),
2705        ],
2706    );
2707    let dir = tempfile::tempdir().unwrap();
2708    let resolver = server.resolver(dir.path());
2709    assert_eq!(
2710        export_requests(&resolver, "npm:scoped@1/sub/"),
2711        ["npm:scoped@1/sub", "npm:scoped@1/sub/deep"]
2712    );
2713    // A missing fallback entry does not fail the array; the ESM hit wins.
2714    let deep = block_on(resolver.resolve_request("npm:scoped@1/sub/deep", ResolutionMode::Import))
2715        .unwrap();
2716    assert!(deep.path.to_string_lossy().ends_with("deep.mjs"));
2717}
2718
2719fn tarball_path(name: &str, version: &str) -> String {
2720    let leaf = name.rsplit('/').next().unwrap();
2721    format!("/{name}/-/{leaf}-{version}.tgz")
2722}
2723
2724fn wait_for_requests(server: &Registry, want: &[String], since: usize) {
2725    use std::sync::atomic::Ordering;
2726    use std::time::{Duration, Instant};
2727    let deadline = Instant::now() + Duration::from_secs(15);
2728    loop {
2729        let logged = server.requests.lock().unwrap();
2730        if want.iter().all(|path| logged[since..].contains(path)) {
2731            return;
2732        }
2733        drop(logged);
2734        if Instant::now() > deadline {
2735            server.hold_tarballs.store(false, Ordering::Relaxed);
2736            server.hold_paths.lock().unwrap().clear();
2737            panic!("timed out waiting for requests {want:?}");
2738        }
2739        std::thread::sleep(Duration::from_millis(10));
2740    }
2741}
2742
2743fn wait_for_responses(server: &Registry, want: &[String], since: usize) {
2744    use std::sync::atomic::Ordering;
2745    use std::time::{Duration, Instant};
2746    let deadline = Instant::now() + Duration::from_secs(15);
2747    loop {
2748        let done = server.responses.lock().unwrap();
2749        if want.iter().all(|path| done[since..].contains(path)) {
2750            return;
2751        }
2752        drop(done);
2753        if Instant::now() > deadline {
2754            server.hold_tarballs.store(false, Ordering::Relaxed);
2755            server.hold_paths.lock().unwrap().clear();
2756            panic!("timed out waiting for responses {want:?}");
2757        }
2758        std::thread::sleep(Duration::from_millis(10));
2759    }
2760}
2761
2762fn wait_for_materialized(events: &Arc<std::sync::Mutex<Vec<String>>>, want: &str) {
2763    use std::time::{Duration, Instant};
2764    let deadline = Instant::now() + Duration::from_secs(15);
2765    loop {
2766        if events.lock().unwrap().iter().any(|event| event == want) {
2767            return;
2768        }
2769        if Instant::now() > deadline {
2770            panic!("timed out waiting for materialized {want:?}");
2771        }
2772        std::thread::sleep(Duration::from_millis(10));
2773    }
2774}
2775
2776#[test]
2777fn dependency_archives_download_concurrently() {
2778    use std::sync::atomic::Ordering;
2779    let server = Registry::new();
2780    server.concurrent.store(true, Ordering::Relaxed);
2781    server.publish_package(
2782        "app",
2783        "1.0.0",
2784        serde_json::json!({"dependencies": {"left": "^1.0.0", "right": "^1.0.0"}}),
2785        &[("index.js", "export default 0;")],
2786    );
2787    for name in ["left", "right"] {
2788        server.publish_package(
2789            name,
2790            "1.0.0",
2791            serde_json::json!({}),
2792            &[("index.js", "export default 0;")],
2793        );
2794    }
2795    let held = [
2796        tarball_path("left", "1.0.0"),
2797        tarball_path("right", "1.0.0"),
2798    ];
2799    server
2800        .hold_paths
2801        .lock()
2802        .unwrap()
2803        .extend(held.iter().cloned());
2804    let dir = tempfile::tempdir().unwrap();
2805    let resolver = server.resolver(dir.path());
2806    let handle = std::thread::spawn(move || {
2807        block_on(resolver.prepare(&[NpmRootRequest {
2808            request: "npm:app@1".into(),
2809            context: BTreeMap::new(),
2810        }]))
2811    });
2812    wait_for_requests(&server, &held, 0);
2813    assert!(
2814        !handle.is_finished(),
2815        "both archives must be in flight at once"
2816    );
2817    assert!(
2818        server.max_tarballs.load(Ordering::SeqCst) >= 2,
2819        "downloads must overlap"
2820    );
2821    server.hold_paths.lock().unwrap().clear();
2822    handle.join().unwrap().unwrap();
2823}
2824
2825#[test]
2826fn wide_graphs_stay_within_the_download_bound() {
2827    use std::sync::atomic::Ordering;
2828    let server = Registry::new();
2829    server.concurrent.store(true, Ordering::Relaxed);
2830    let mut deps = serde_json::Map::new();
2831    for index in 0..20 {
2832        let name = format!("leaf{index:02}");
2833        deps.insert(name.clone(), serde_json::Value::String("^1.0.0".into()));
2834        server.publish_package(
2835            &name,
2836            "1.0.0",
2837            serde_json::json!({}),
2838            &[("index.js", "export default 0;")],
2839        );
2840    }
2841    server.publish_package(
2842        "app1",
2843        "1.0.0",
2844        serde_json::json!({"dependencies": serde_json::Value::Object(deps.clone())}),
2845        &[("index.js", "export default 0;")],
2846    );
2847    let mut deps2 = serde_json::Map::new();
2848    for index in 20..30 {
2849        let name = format!("leaf{index:02}");
2850        deps2.insert(name.clone(), serde_json::Value::String("^1.0.0".into()));
2851        server.publish_package(
2852            &name,
2853            "1.0.0",
2854            serde_json::json!({}),
2855            &[("index.js", "export default 0;")],
2856        );
2857    }
2858    server.publish_package(
2859        "app2",
2860        "1.0.0",
2861        serde_json::json!({"dependencies": serde_json::Value::Object(deps2)}),
2862        &[("index.js", "export default 0;")],
2863    );
2864    let dir = tempfile::tempdir().unwrap();
2865    let store =
2866        Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap());
2867    let first = NpmResolver::new(store.clone());
2868    let second = NpmResolver::new(store);
2869    let hold_all: Vec<String> = (0..30)
2870        .map(|index| tarball_path(&format!("leaf{index:02}"), "1.0.0"))
2871        .collect();
2872    server
2873        .hold_paths
2874        .lock()
2875        .unwrap()
2876        .extend(hold_all.iter().cloned());
2877    let prepare = |resolver: NpmResolver, request: &str| {
2878        let request = request.to_owned();
2879        std::thread::spawn(move || {
2880            block_on(resolver.prepare(&[NpmRootRequest {
2881                request,
2882                context: BTreeMap::new(),
2883            }]))
2884        })
2885    };
2886    let first_handle = prepare(first, "npm:app1@1");
2887    let second_handle = prepare(second, "npm:app2@1");
2888    {
2889        use std::time::{Duration, Instant};
2890        let deadline = Instant::now() + Duration::from_secs(15);
2891        while server.active_tarballs.load(Ordering::SeqCst) < 8 {
2892            if Instant::now() > deadline {
2893                server.hold_paths.lock().unwrap().clear();
2894                panic!("parallel downloads never reached the bound");
2895            }
2896            std::thread::sleep(Duration::from_millis(10));
2897        }
2898    }
2899    let max = server.max_tarballs.load(Ordering::SeqCst);
2900    assert!(max > 1, "downloads must overlap: {max}");
2901    assert!(max <= 8, "downloads must stay bounded: {max}");
2902    server.hold_paths.lock().unwrap().clear();
2903    first_handle.join().unwrap().unwrap();
2904    second_handle.join().unwrap().unwrap();
2905    assert!(
2906        server.max_tarballs.load(Ordering::SeqCst) <= 8,
2907        "bound holds through completion"
2908    );
2909}
2910
2911#[test]
2912fn same_name_aliases_resolve_independently_of_sibling_timing() {
2913    use std::sync::atomic::Ordering;
2914    let server = Registry::new();
2915    server.concurrent.store(true, Ordering::Relaxed);
2916    for version in ["1.0.0", "2.0.0"] {
2917        server.publish_package(
2918            "foo",
2919            version,
2920            serde_json::json!({}),
2921            &[("index.js", "export default 0;")],
2922        );
2923    }
2924    for name in ["x", "y"] {
2925        server.publish_package(
2926            name,
2927            "1.0.0",
2928            serde_json::json!({}),
2929            &[("index.js", "export default 0;")],
2930        );
2931    }
2932    server.publish_package(
2933        "app",
2934        "1.0.0",
2935        serde_json::json!({"dependencies": {
2936            "a": "npm:foo@^1.0.0",
2937            "b": "npm:foo@>=1.0.0 <3.0.0",
2938            "x": "npm:x@1",
2939            "y": "npm:y@1",
2940        }}),
2941        &[("index.js", "export default 0;")],
2942    );
2943    let run = |held: &str| {
2944        use avril_runtime::registry::ProgressEvent;
2945        let since = server.requests.lock().unwrap().len();
2946        let dir = tempfile::tempdir().unwrap();
2947        let materialized = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2948        let sink_materialized = materialized.clone();
2949        let store = Arc::new(
2950            PackageStore::new(fixture::runtime_handle(), server.options(dir.path()))
2951                .unwrap()
2952                .with_progress(Arc::new(move |event| {
2953                    if let ProgressEvent::NpmMaterialized { name, version } = event {
2954                        sink_materialized
2955                            .lock()
2956                            .unwrap()
2957                            .push(format!("{name}@{version}"));
2958                    }
2959                })),
2960        );
2961        let resolver = NpmResolver::new(store.clone());
2962        server
2963            .hold_paths
2964            .lock()
2965            .unwrap()
2966            .insert(tarball_path(held, "1.0.0"));
2967        let handle = std::thread::spawn(move || {
2968            block_on(resolver.prepare(&[NpmRootRequest {
2969                request: "npm:app@1".into(),
2970                context: BTreeMap::new(),
2971            }]))
2972        });
2973        let other = if held == "x" { "y" } else { "x" };
2974        wait_for_requests(&server, &[tarball_path(held, "1.0.0")], since);
2975        wait_for_materialized(&materialized, &format!("{other}@1.0.0"));
2976        server.hold_paths.lock().unwrap().clear();
2977        handle.join().unwrap().unwrap();
2978        store.save().unwrap();
2979        let lock = fixture::read_lock(dir.path());
2980        assert_eq!(lock["npm"]["specifiers"]["npm:foo@^1.0.0"], "1.0.0");
2981        assert_eq!(lock["npm"]["specifiers"]["npm:foo@>=1.0.0 <3.0.0"], "1.0.0");
2982        lock
2983    };
2984    let first = run("x");
2985    let second = run("y");
2986    assert_eq!(first, second);
2987}
2988
2989#[test]
2990fn sibling_failures_report_the_first_request_and_retry_cleanly() {
2991    use std::sync::atomic::Ordering;
2992    let server = Registry::new();
2993    server.concurrent.store(true, Ordering::Relaxed);
2994    for name in ["bad1", "bad2", "good"] {
2995        server.publish_package(
2996            name,
2997            "1.0.0",
2998            serde_json::json!({}),
2999            &[("index.js", "export default 0;")],
3000        );
3001    }
3002    let bad2_tarball = tarball_path("bad2", "1.0.0");
3003    let bad1_tarball = tarball_path("bad1", "1.0.0");
3004    let good_tarball = tarball_path("good", "1.0.0");
3005    let bad2_original = server.files.lock().unwrap()[&bad2_tarball].clone();
3006    server.files.lock().unwrap().remove(&bad1_tarball);
3007    server
3008        .files
3009        .lock()
3010        .unwrap()
3011        .insert(bad2_tarball.clone(), b"not gzip".to_vec());
3012    server
3013        .hold_paths
3014        .lock()
3015        .unwrap()
3016        .insert(bad1_tarball.clone());
3017    server.publish_package(
3018        "app",
3019        "1.0.0",
3020        serde_json::json!({"dependencies": {"abad": "npm:bad1@1", "bads": "npm:bad2@1", "fine": "npm:good@1"}}),
3021        &[
3022            ("index.js", "export default 0;"),
3023        ],
3024    );
3025    let dir = tempfile::tempdir().unwrap();
3026    let store =
3027        Arc::new(PackageStore::new(fixture::runtime_handle(), server.options(dir.path())).unwrap());
3028    let resolver = Arc::new(NpmResolver::new(store));
3029    let logged = server.requests.lock().unwrap().len();
3030    let responded = server.responses.lock().unwrap().len();
3031    let worker = resolver.clone();
3032    let handle = std::thread::spawn(move || {
3033        block_on(worker.prepare(&[NpmRootRequest {
3034            request: "npm:app@1".into(),
3035            context: BTreeMap::new(),
3036        }]))
3037    });
3038    wait_for_requests(&server, std::slice::from_ref(&bad1_tarball), logged);
3039    wait_for_responses(&server, &[bad2_tarball.clone(), good_tarball], responded);
3040    server.hold_paths.lock().unwrap().clear();
3041    let error = handle.join().unwrap().unwrap_err().to_string();
3042    assert!(
3043        error.contains("bad1"),
3044        "first input error must win: {error:?}"
3045    );
3046    server
3047        .files
3048        .lock()
3049        .unwrap()
3050        .insert(bad2_tarball, bad2_original);
3051    server.publish_package(
3052        "bad1",
3053        "1.0.0",
3054        serde_json::json!({}),
3055        &[("index.js", "export default 0;")],
3056    );
3057    let handle = block_on(resolver.prepare(&[NpmRootRequest {
3058        request: "npm:app@1".into(),
3059        context: BTreeMap::new(),
3060    }]))
3061    .unwrap();
3062    assert!(!handle.roots.is_empty(), "retry must establish roots");
3063}
3064
3065#[test]
3066fn export_requests_propagates_resolution_errors() {
3067    let server = Registry::new();
3068    server.publish_package(
3069        "gone",
3070        "1.0.0",
3071        serde_json::json!({"exports": {".": "./missing.js"}}),
3072        &[("index.js", "export default 0;")],
3073    );
3074    let dir = tempfile::tempdir().unwrap();
3075    let resolver = server.resolver(dir.path());
3076    block_on(resolver.export_requests("npm:gone@1")).unwrap_err();
3077}