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
79.7 KiB2462 linesraw
1#[path = "common/registry.rs"]
2mod fixture;
3
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8use fixture::{Registry, failure, success};
9
10#[test]
11fn jsr_and_npm_registries_are_independent_and_persisted() {
12    let jsr = Registry::new();
13    let npm = Registry::new();
14    jsr.package(
15        "@test/jsr",
16        "1.0.0",
17        &[("mod.ts", "export default 'jsr';")],
18        &[(".", "./mod.ts")],
19    );
20    npm.publish_package(
21        "npm-only",
22        "1.0.0",
23        serde_json::json!({}),
24        &[("index.js", "export default 'npm';")],
25    );
26    let dir = tempfile::tempdir().unwrap();
27    fs::write(
28        dir.path().join("main.ts"),
29        "import jsr from 'jsr:@test/jsr@1'; import npm from 'npm:npm-only@1'; console.log(jsr, npm);",
30    )
31    .unwrap();
32    let run = |jsr_url: &url::Url, npm_url: &url::Url, args: &[&str]| {
33        Command::new(env!("CARGO_BIN_EXE_avril"))
34            .current_dir(dir.path())
35            .arg("run")
36            .arg(format!("--jsr-registry={jsr_url}"))
37            .arg(format!("--npm-registry={npm_url}"))
38            .arg("--cache-dir=cache")
39            .args(args)
40            .arg("main.ts")
41            .output()
42            .unwrap()
43    };
44    assert_eq!(success(&run(&jsr.url, &npm.url, &[])), "jsr npm\n");
45    let lock = fixture::read_lock(dir.path());
46    assert_eq!(lock["jsrRegistry"], jsr.url.as_str());
47    assert_eq!(lock["npmRegistry"], npm.url.as_str());
48    for old_name in ["registry", "jsr_registry", "npm_registry"] {
49        assert!(lock.get(old_name).is_none());
50    }
51    let jsr_requests = jsr.requests.lock().unwrap().len();
52    let npm_requests = npm.requests.lock().unwrap().len();
53    assert!(jsr_requests > 0 && npm_requests > 0);
54    assert_eq!(
55        success(&run(&jsr.url, &npm.url, &["--offline", "--frozen"])),
56        "jsr npm\n"
57    );
58    assert!(failure(&run(&npm.url, &npm.url, &["--offline"])).contains("JSR registry"));
59    assert!(failure(&run(&jsr.url, &jsr.url, &["--offline"])).contains("npm registry"));
60    assert_eq!(jsr.requests.lock().unwrap().len(), jsr_requests);
61    assert_eq!(npm.requests.lock().unwrap().len(), npm_requests);
62}
63
64#[test]
65fn scoped_and_unscoped_config_dependencies_run() {
66    let server = Registry::new();
67    server.publish_package(
68        "leftpad",
69        "1.0.0",
70        serde_json::json!({}),
71        &[("index.js", "export default 'pad';")],
72    );
73    server.publish_package(
74        "@scope/widget",
75        "2.0.0",
76        serde_json::json!({}),
77        &[("index.js", "export default 'widget';")],
78    );
79    let dir = tempfile::tempdir().unwrap();
80    fs::write(
81        dir.path().join("avril.conf"),
82        "deps: ['npm:leftpad@1', 'npm:@scope/widget@^2']",
83    )
84    .unwrap();
85    fs::write(
86        dir.path().join("main.ts"),
87        "import pad from 'leftpad'; import widget from '@scope/widget'; console.log(pad, widget);",
88    )
89    .unwrap();
90    assert_eq!(
91        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
92        "pad widget\n"
93    );
94}
95
96#[test]
97fn import_map_aliases_and_prefixes_reach_subpaths() {
98    let server = Registry::new();
99    server.publish_package(
100        "real",
101        "1.0.0",
102        serde_json::json!({"exports": {".": "./mod.js", "./sub": "./sub.js"}}),
103        &[
104            ("mod.js", "export default 'mod';"),
105            ("sub.js", "export default 'sub';"),
106        ],
107    );
108    let dir = tempfile::tempdir().unwrap();
109    fs::write(
110        dir.path().join("avril.conf"),
111        "imports: {alias: 'npm:real@1', 'pref/': 'npm:real@1/'}",
112    )
113    .unwrap();
114    fs::write(
115        dir.path().join("main.ts"),
116        "import a from 'alias'; import b from 'pref/sub'; console.log(a, b);",
117    )
118    .unwrap();
119    assert_eq!(
120        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
121        "mod sub\n"
122    );
123}
124
125#[test]
126fn transitive_nested_esm_imports_coexist() {
127    let server = Registry::new();
128    server.publish_package(
129        "util",
130        "1.0.0",
131        serde_json::json!({}),
132        &[("index.js", "export const u = 'u1';")],
133    );
134    server.publish_package(
135        "util",
136        "2.0.0",
137        serde_json::json!({}),
138        &[("index.js", "export const u = 'u2';")],
139    );
140    server.publish_package(
141        "lib",
142        "1.0.0",
143        serde_json::json!({"dependencies": {"util": "^2.0.0"}}),
144        &[
145            (
146                "index.js",
147                "export {v as nested} from './deep/nested.js'; export {u} from 'util';",
148            ),
149            ("deep/nested.js", "export {v} from './sibling.js';"),
150            ("deep/sibling.js", "export {v} from '../shared.js';"),
151            ("shared.js", "export const v = 'shared';"),
152        ],
153    );
154    server.publish_package(
155        "app",
156        "1.0.0",
157        serde_json::json!({"dependencies": {"lib": "^1.0.0", "util": "^1.0.0"}}),
158        &[(
159            "index.js",
160            "export * from 'lib'; export {u as u1} from 'util';",
161        )],
162    );
163    let dir = tempfile::tempdir().unwrap();
164    fs::write(dir.path().join("avril.conf"), "deps: ['npm:app@1']").unwrap();
165    fs::write(
166        dir.path().join("main.ts"),
167        "import {nested, u, u1} from 'app'; console.log(nested, u, u1);",
168    )
169    .unwrap();
170    assert_eq!(
171        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
172        "shared u2 u1\n"
173    );
174}
175
176#[test]
177fn dynamic_imports_share_namespaces_concurrently() {
178    let server = Registry::new();
179    server.publish_package(
180        "dyn",
181        "1.0.0",
182        serde_json::json!({}),
183        &[("index.js", "export const value = 7;")],
184    );
185    let dir = tempfile::tempdir().unwrap();
186    fs::write(
187        dir.path().join("main.ts"),
188        "const [a, b] = await Promise.all([import('npm:dyn@1'), import('npm:dyn@1')]); console.log(a === b, a.value);",
189    )
190    .unwrap();
191    assert_eq!(
192        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
193        "true 7\n"
194    );
195}
196
197#[test]
198fn peer_auto_install_shares_explicit_providers() {
199    let server = Registry::new();
200    server.publish_package(
201        "react",
202        "18.2.0",
203        serde_json::json!({}),
204        &[
205            (
206                "index.js",
207                "globalThis.count = (globalThis.count ?? 0) + 1; export const count = globalThis.count;",
208            ),
209        ],
210    );
211    server.publish_package(
212        "plugin",
213        "1.0.0",
214        serde_json::json!({"peerDependencies": {"react": "^18.0.0"}}),
215        &[("index.js", "export {count} from 'react';")],
216    );
217    let dir = tempfile::tempdir().unwrap();
218    fs::write(
219        dir.path().join("avril.conf"),
220        "deps: ['npm:react@18.2.0', 'npm:plugin@1']",
221    )
222    .unwrap();
223    fs::write(
224        dir.path().join("main.ts"),
225        "import {count as direct} from 'react'; import {count as via} from 'plugin'; console.log(direct, via);",
226    )
227    .unwrap();
228    assert_eq!(
229        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
230        "1 1\n"
231    );
232}
233
234#[test]
235fn direct_imports_preselect_peers_without_config() {
236    let server = Registry::new();
237    server.publish_package(
238        "host",
239        "1.0.0",
240        serde_json::json!({}),
241        &[("index.js", "export const host = 'h1';")],
242    );
243    server.publish_package(
244        "plugin",
245        "1.0.0",
246        serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}),
247        &[("index.js", "export {host} from 'host';")],
248    );
249    let dir = tempfile::tempdir().unwrap();
250    fs::write(
251        dir.path().join("main.ts"),
252        "import {host} from 'npm:plugin@1'; import {host as direct} from 'npm:host@1.0.0'; console.log(host, direct);",
253    )
254    .unwrap();
255    assert_eq!(
256        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
257        "h1 h1\n"
258    );
259}
260
261#[test]
262fn two_configs_isolate_peer_environments() {
263    let server = Registry::new();
264    for version in ["1.0.0", "2.0.0"] {
265        server.publish_package(
266            "host",
267            version,
268            serde_json::json!({}),
269            &[("index.js", &format!("export const version = '{version}';"))],
270        );
271    }
272    server.publish_package(
273        "plugin",
274        "1.0.0",
275        serde_json::json!({"peerDependencies": {"host": "*"}}),
276        &[("index.js", "export {version} from 'host';")],
277    );
278    let dir = tempfile::tempdir().unwrap();
279    for (package, host) in [("a", "1.0.0"), ("b", "2.0.0")] {
280        let root = dir.path().join(package);
281        fs::create_dir_all(&root).unwrap();
282        fs::write(
283            root.join("avril.conf"),
284            format!("deps: ['npm:host@{host}', 'npm:plugin@1']"),
285        )
286        .unwrap();
287        fs::write(
288            root.join("main.ts"),
289            "import {version} from 'plugin'; console.log(version);",
290        )
291        .unwrap();
292    }
293    fs::write(
294        dir.path().join("main.ts"),
295        "import './a/main.ts'; import './b/main.ts';",
296    )
297    .unwrap();
298    assert_eq!(
299        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
300        "1.0.0\n2.0.0\n"
301    );
302}
303
304#[test]
305fn shadowed_peers_use_the_parent_host_instance() {
306    let server = Registry::new();
307    server.publish_package(
308        "x",
309        "1.0.0",
310        serde_json::json!({}),
311        &[("index.js", "export const x = 'x1';")],
312    );
313    server.publish_package(
314        "x",
315        "2.0.0",
316        serde_json::json!({}),
317        &[("index.js", "export const x = 'x2';")],
318    );
319    server.publish_package(
320        "host",
321        "1.0.0",
322        serde_json::json!({"peerDependencies": {"x": "^1.0.0"}}),
323        &[("index.js", "export {x} from 'x';")],
324    );
325    server.publish_package(
326        "plugin",
327        "1.0.0",
328        serde_json::json!({"dependencies": {"host": "^1.0.0", "x": "^2.0.0"}}),
329        &[(
330            "index.js",
331            "export {x as own} from 'x'; export {x as hosted} from 'host';",
332        )],
333    );
334    let dir = tempfile::tempdir().unwrap();
335    fs::write(dir.path().join("avril.conf"), "deps: ['npm:plugin@1']").unwrap();
336    fs::write(
337        dir.path().join("main.ts"),
338        "import {own, hosted} from 'plugin'; console.log(own, hosted);",
339    )
340    .unwrap();
341    assert_eq!(
342        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
343        "x2 x1\n"
344    );
345}
346
347#[test]
348fn typescript_sources_transpile() {
349    let server = Registry::new();
350    server.publish_package(
351        "typed",
352        "1.0.0",
353        serde_json::json!({"exports": {".": "./index.ts"}}),
354        &[("index.ts", "export const value: number = 40 + 2;")],
355    );
356    let dir = tempfile::tempdir().unwrap();
357    fs::write(
358        dir.path().join("main.ts"),
359        "import {value} from 'npm:typed@1'; console.log(value);",
360    )
361    .unwrap();
362
363    assert_eq!(
364        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
365        "42\n"
366    );
367}
368
369#[test]
370fn unsupported_kinds_fail_with_clear_errors() {
371    let server = Registry::new();
372    server.publish_package(
373        "cjspkg",
374        "1.0.0",
375        serde_json::json!({"type": "commonjs"}),
376        &[("index.js", "module.exports = 1;")],
377    );
378    server.publish_package(
379        "data",
380        "1.0.0",
381        serde_json::json!({"exports": {".": "./data.json"}}),
382        &[("data.json", "{\"a\": 1}")],
383    );
384    let dir = tempfile::tempdir().unwrap();
385    // CommonJS is now supported via ESM wrappers and `require`; JSON imports
386    // and Node builtins stay unsupported with clear errors.
387    fs::write(
388        dir.path().join("main.ts"),
389        "import def from 'npm:cjspkg@1'; console.log(def);",
390    )
391    .unwrap();
392    assert_eq!(
393        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
394        "1\n"
395    );
396    fs::write(dir.path().join("main.ts"), "import 'npm:data@1';").unwrap();
397    assert!(failure(&server.run_npm(dir.path(), &["run"], "main.ts")).contains("JSON"));
398    fs::write(dir.path().join("main.ts"), "import 'node:fs';").unwrap();
399    assert!(
400        failure(&server.run_npm(dir.path(), &["run"], "main.ts"))
401            .contains("node: builtin modules are not supported")
402    );
403}
404
405#[test]
406fn revoked_cached_imports_and_guest_io_stay_denied() {
407    let server = Registry::new();
408    server.publish_package(
409        "sec",
410        "1.0.0",
411        serde_json::json!({}),
412        &[("index.js", "export const value = 42;")],
413    );
414    let dir = tempfile::tempdir().unwrap();
415    let grant = format!("--allow-js-exec={}sec/1.0.0/", server.url);
416    fs::write(
417        dir.path().join("main.ts"),
418        "import {value} from 'npm:sec@1'; import {pledge} from 'avril:std'; console.log(value); pledge({jsExec: false}); try { await import('npm:sec@1'); throw new Error('allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }",
419    )
420    .unwrap();
421    let output = server.run_npm(dir.path(), &["run", "-0", &grant], "main.ts");
422    assert_eq!(success(&output), "42\ntrue\n");
423    fs::write(
424        dir.path().join("main.ts"),
425        "import {value} from 'npm:sec@1'; console.log(value); try { await fetch('http://127.0.0.1:9/'); throw new Error('net allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }",
426    )
427    .unwrap();
428    let output = server.run_npm(dir.path(), &["run", "-0", &grant], "main.ts");
429    assert_eq!(success(&output), "42\ntrue\n");
430}
431
432#[test]
433fn reserved_tree_bypass_attempts_fail() {
434    let server = Registry::new();
435    server.publish_package(
436        "guard",
437        "1.0.0",
438        serde_json::json!({}),
439        &[("index.js", "export default 1;")],
440    );
441    let dir = tempfile::tempdir().unwrap();
442    fs::write(
443        dir.path().join("main.ts"),
444        "import 'npm:guard@1'; console.log('warmed');",
445    )
446    .unwrap();
447    assert_eq!(
448        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
449        "warmed\n"
450    );
451    let tree = dir.path().join(".avril").join("npm");
452    let target = tree
453        .join("default")
454        .read_dir()
455        .unwrap()
456        .next()
457        .unwrap()
458        .unwrap()
459        .path();
460    let target = target.join("guard").join("1.0.0").join("index.js");
461    assert!(target.is_file());
462    let relative = target.strip_prefix(dir.path()).unwrap();
463    let relative_url = url::Url::from_file_path(&target).unwrap();
464    // Without virtual authority even the normal import fails, and direct
465    // reserved-tree imports fail the same way: local grants never cover
466    // the npm tree.
467    fs::write(dir.path().join("main.ts"), "import 'npm:guard@1';").unwrap();
468    let output = server.run_npm(dir.path(), &["run", "-0", "--allow-js-exec=./"], "main.ts");
469    assert!(
470        failure(&output).contains("PermissionDenied"),
471        "{}",
472        String::from_utf8_lossy(&output.stderr)
473    );
474    // Prepared but unauthorized: the tree is owned, so bypass attempts
475    // fail on authority rather than preparation.
476    let grant = format!("--allow-js-exec={}guard/1.0.0/", server.url);
477    fs::write(
478        dir.path().join("main.ts"),
479        format!(
480            "import 'npm:guard@1';\n\
481             import {{pledge}} from 'avril:std';\n\
482             pledge({{jsExec: false}});\n\
483             for (const source of ['./{}', '{}']) {{\n\
484                 try {{ await import(source); throw new Error(source + ' allowed'); }}\n\
485                 catch (e) {{ if (!e.message.includes('PermissionDenied')) throw e; }}\n\
486             }}\n\
487             console.log('denied');",
488            relative.display(),
489            relative_url,
490        ),
491    )
492    .unwrap();
493    let output = server.run_npm(
494        dir.path(),
495        &["run", "-0", "--allow-js-exec=./", grant.as_str()],
496        "main.ts",
497    );
498    assert_eq!(success(&output), "denied\n");
499    #[cfg(unix)]
500    {
501        std::os::unix::fs::symlink(&target, dir.path().join("link.js")).unwrap();
502        fs::write(
503            dir.path().join("main.ts"),
504            "import 'npm:guard@1'; await import('./link.js');",
505        )
506        .unwrap();
507        let output = server.run_npm(dir.path(), &["run", "-0", "--allow-js-exec=./"], "main.ts");
508        assert!(!output.status.success());
509    }
510}
511
512#[test]
513fn application_maps_do_not_leak_into_packages() {
514    let server = Registry::new();
515    server.publish_package(
516        "lib",
517        "1.0.0",
518        serde_json::json!({}),
519        &[("index.js", "import 'evil'; export default 1;")],
520    );
521    let dir = tempfile::tempdir().unwrap();
522    fs::write(
523        dir.path().join("avril.conf"),
524        "imports: {evil: './evil.ts'}\ndeps: ['npm:lib@1']",
525    )
526    .unwrap();
527    fs::write(
528        dir.path().join("evil.ts"),
529        "throw new Error('app map leaked');",
530    )
531    .unwrap();
532    fs::write(dir.path().join("main.ts"), "import 'npm:lib@1';").unwrap();
533    assert!(
534        failure(&server.run_npm(dir.path(), &["run"], "main.ts"))
535            .contains("not a declared dependency")
536    );
537}
538
539#[test]
540fn project_package_json_never_changes_resolution() {
541    let server = Registry::new();
542    server.publish_package(
543        "esm",
544        "1.0.0",
545        serde_json::json!({}),
546        &[("index.js", "export const value = 'esm';")],
547    );
548    let dir = tempfile::tempdir().unwrap();
549    fs::write(
550        dir.path().join("package.json"),
551        "{\"type\": \"commonjs\", \"dependencies\": {\"esm\": \"evil\"}}",
552    )
553    .unwrap();
554    fs::write(
555        dir.path().join("main.ts"),
556        "import {value} from 'npm:esm@1'; console.log(value);",
557    )
558    .unwrap();
559    assert_eq!(
560        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
561        "esm\n"
562    );
563}
564
565#[test]
566fn planted_avril_configs_are_not_authorities() {
567    let server = Registry::new();
568    server.publish_package(
569        "pkg",
570        "1.0.0",
571        serde_json::json!({}),
572        &[("index.js", "export default 1;")],
573    );
574    let dir = tempfile::tempdir().unwrap();
575    fs::write(dir.path().join("avril.conf"), "deps: ['npm:pkg@1']").unwrap();
576    fs::write(
577        dir.path().join("main.ts"),
578        "import value from 'pkg'; console.log(value);",
579    )
580    .unwrap();
581    assert_eq!(
582        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
583        "1\n"
584    );
585    let planted = dir.path().join(".avril").join("npm").join("default");
586    fs::write(planted.join("avril.conf"), "imports: {pkg: './evil.ts'}").unwrap();
587    fs::write(planted.join("evil.ts"), "throw new Error('planted');").unwrap();
588    assert_eq!(
589        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
590        "1\n"
591    );
592}
593
594#[test]
595fn offline_frozen_relocated_trees_run_read_only() {
596    let server = Registry::new();
597    server.publish_package(
598        "fro",
599        "1.0.0",
600        serde_json::json!({}),
601        &[("index.js", "export const value = 'fro';")],
602    );
603    let warm = tempfile::tempdir().unwrap();
604    fs::write(warm.path().join("avril.conf"), "deps: ['npm:fro@1']").unwrap();
605    fs::write(
606        warm.path().join("main.ts"),
607        "import {value} from 'fro'; console.log(value);",
608    )
609    .unwrap();
610    assert_eq!(
611        success(&server.run_npm(warm.path(), &["cache"], "main.ts")),
612        ""
613    );
614    let dir = tempfile::tempdir().unwrap();
615    fs::write(dir.path().join("avril.conf"), "deps: ['npm:fro@1']").unwrap();
616    fs::write(
617        dir.path().join("main.ts"),
618        "import {value} from 'fro'; console.log(value);",
619    )
620    .unwrap();
621    fn copy_tree(from: &Path, to: &Path) {
622        fs::create_dir_all(to).unwrap();
623        for entry in fs::read_dir(from).unwrap() {
624            let entry = entry.unwrap();
625            let target = to.join(entry.file_name());
626            if entry.file_type().unwrap().is_dir() {
627                copy_tree(&entry.path(), &target);
628            } else {
629                fs::copy(entry.path(), target).unwrap();
630            }
631        }
632    }
633    copy_tree(
634        &warm.path().join(".avril").join("npm"),
635        &dir.path().join(".avril").join("npm"),
636    );
637    fs::copy(
638        warm.path().join("avril.lock"),
639        dir.path().join("avril.lock"),
640    )
641    .unwrap();
642    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".metadata")).unwrap();
643    std::fs::remove_dir_all(dir.path().join(".avril").join("npm").join(".artifacts")).unwrap();
644    #[cfg(unix)]
645    {
646        use std::os::unix::fs::PermissionsExt;
647        let tree = dir.path().join(".avril");
648        let mut stack = vec![tree.clone()];
649        while let Some(path) = stack.pop() {
650            for entry in fs::read_dir(&path).unwrap() {
651                let entry = entry.unwrap().path();
652                if entry.is_dir() {
653                    stack.push(entry);
654                }
655            }
656            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o555)).unwrap();
657        }
658        std::fs::set_permissions(
659            dir.path().join("avril.lock"),
660            std::fs::Permissions::from_mode(0o444),
661        )
662        .unwrap();
663    }
664    let before = server.requests.lock().unwrap().len();
665    let output = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts");
666    assert_eq!(success(&output), "fro\n");
667    assert_eq!(server.requests.lock().unwrap().len(), before);
668}
669
670#[test]
671fn info_lists_npm_modules_with_package_metadata() {
672    let server = Registry::new();
673    server.publish_package(
674        "inf",
675        "1.0.0",
676        serde_json::json!({}),
677        &[("index.js", "export default 1;")],
678    );
679    let dir = tempfile::tempdir().unwrap();
680    fs::write(dir.path().join("avril.conf"), "deps: ['npm:inf@1']").unwrap();
681    fs::write(dir.path().join("main.ts"), "import 'inf';").unwrap();
682    let output = server.run_npm(dir.path(), &["info"], "main.ts");
683    let info: serde_json::Value = serde_json::from_str(&success(&output)).unwrap();
684    let modules = info["modules"].as_object().unwrap();
685    let (path, module) = modules
686        .iter()
687        .find(|(_, module)| {
688            module["package"]["name"] == "inf" && module["package"]["version"] == "1.0.0"
689        })
690        .unwrap();
691    assert!(path.contains(".avril"));
692    assert_eq!(
693        module["url"]
694            .as_str()
695            .unwrap()
696            .strip_prefix("file://")
697            .unwrap(),
698        path.as_str()
699    );
700}
701
702#[test]
703fn execution_ignores_unused_corruption_but_cache_audits_it() {
704    let server = Registry::new();
705    server.publish_npm(
706        "demand-audit",
707        "1.0.0",
708        &[
709            (
710                "package.json",
711                r#"{"name":"demand-audit","version":"1.0.0","type":"module"}"#,
712            ),
713            ("index.js", "export const value = 7;"),
714            ("README.md", "untouched"),
715        ],
716    );
717    let dir = tempfile::tempdir().unwrap();
718    fs::write(
719        dir.path().join("main.ts"),
720        "import { value } from 'npm:demand-audit@1'; console.log(value);",
721    )
722    .unwrap();
723    assert_eq!(
724        success(&server.run_npm(dir.path(), &["run"], "main.ts")).trim(),
725        "7"
726    );
727    let package = dir.path().join(".avril/npm/default");
728    let registry = fs::read_dir(package)
729        .unwrap()
730        .next()
731        .unwrap()
732        .unwrap()
733        .path();
734    fs::write(registry.join("demand-audit/1.0.0/README.md"), "tampered").unwrap();
735    assert_eq!(
736        success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")).trim(),
737        "7"
738    );
739    let audit = server.run_npm(dir.path(), &["cache", "--offline", "--frozen"], "main.ts");
740    assert!(!audit.status.success(), "cache audit unexpectedly passed");
741    assert!(String::from_utf8_lossy(&audit.stderr).contains("README.md"));
742    fs::write(
743        registry.join("demand-audit/1.0.0/index.js"),
744        "export const value = 8;",
745    )
746    .unwrap();
747    let used_file = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts");
748    assert!(
749        !used_file.status.success(),
750        "tampered module unexpectedly ran"
751    );
752    assert!(String::from_utf8_lossy(&used_file.stderr).contains("index.js"));
753    fs::write(
754        registry.join("demand-audit/1.0.0/index.js"),
755        "export const value = 7;",
756    )
757    .unwrap();
758    fs::write(
759        registry.join("demand-audit/1.0.0/package.json"),
760        r#"{"name":"changed","version":"1.0.0","type":"module"}"#,
761    )
762    .unwrap();
763    let used_metadata = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts");
764    assert!(
765        !used_metadata.status.success(),
766        "tampered package metadata passed"
767    );
768    assert!(String::from_utf8_lossy(&used_metadata.stderr).contains("package.json"));
769}
770
771#[cfg(unix)]
772#[test]
773fn npm_file_urls_cannot_redirect_source_through_symlinks() {
774    let server = Registry::new();
775    server.publish_package("url-dependency", "1.0.0", serde_json::json!({}), &[]);
776    server.publish_package(
777        "url-guard",
778        "1.0.0",
779        serde_json::json!({
780            "dependencies": {"url-dependency": "1"},
781            "exports": {".": "./index.js", "./prepare": "./prepare.js"}
782        }),
783        &[
784            ("index.js", "export const value = 'verified';"),
785            ("prepare.js", "export {};"),
786        ],
787    );
788    let dir = tempfile::tempdir().unwrap();
789    let main = dir.path().join("main.js");
790    fs::write(
791        &main,
792        "import 'npm:url-guard@1/prepare'; console.log(import.meta.resolve('npm:url-guard@1'));",
793    )
794    .unwrap();
795    let url = success(&server.run_npm(dir.path(), &["run"], "main.js"));
796    let source = url::Url::parse(url.trim()).unwrap().to_file_path().unwrap();
797    let outside = dir.path().join("outside.js");
798    fs::write(&outside, "export const value = 'UNVERIFIED';").unwrap();
799    fs::remove_file(&source).unwrap();
800    std::os::unix::fs::symlink(&outside, &source).unwrap();
801    fs::write(
802        &main,
803        "import 'npm:url-guard@1/prepare'; console.log((await import(import.meta.resolve('npm:url-guard@1'))).value);",
804    )
805    .unwrap();
806    let output = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.js");
807    assert!(failure(&output).contains("npm file"));
808    assert!(!String::from_utf8_lossy(&output.stdout).contains("UNVERIFIED"));
809}
810
811#[test]
812fn extensionless_local_require_resolves_prepared_npm_manifest_entry() {
813    let server = Registry::new();
814    server.publish_package(
815        "extensionless-demand",
816        "1.0.0",
817        serde_json::json!({
818            "type": "module",
819            "exports": {".": "./index.js", "./cjs": "./cjs/index.js"}
820        }),
821        &[
822            ("index.js", "export const loaded = true;"),
823            ("cjs/package.json", "{\"type\":\"commonjs\"}"),
824            ("cjs/index.js", "exports.value = 'verified-cjs';"),
825        ],
826    );
827    let dir = tempfile::tempdir().unwrap();
828    fs::write(
829        dir.path().join("main.js"),
830        "import 'npm:extensionless-demand@1'; await import('./extensionless.cjs');",
831    )
832    .unwrap();
833    fs::write(
834        dir.path().join("extensionless.cjs"),
835        "const entry = require.resolve('npm:extensionless-demand@1/cjs'); console.log(require(entry.slice(0, -3)).value);",
836    )
837    .unwrap();
838    assert_eq!(
839        success(&server.run_npm(dir.path(), &["run"], "main.js")),
840        "verified-cjs\n"
841    );
842}
843
844#[test]
845fn failed_imports_do_not_poison_later_runs() {
846    let server = Registry::new();
847    server.publish_package("ok", "1.0.0", serde_json::json!({}), &[]);
848    let dir = tempfile::tempdir().unwrap();
849    fs::write(dir.path().join("main.ts"), "import 'npm:ghost@9';").unwrap();
850    assert!(
851        !server
852            .run_npm(dir.path(), &["run"], "main.ts")
853            .status
854            .success()
855    );
856    fs::write(
857        dir.path().join("main.ts"),
858        "import 'npm:ok@1'; console.log('healed');",
859    )
860    .unwrap();
861    assert_eq!(
862        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
863        "healed\n"
864    );
865}
866
867#[test]
868fn import_meta_urls_resolve_to_the_same_instance() {
869    let server = Registry::new();
870    server.publish_package(
871        "meta",
872        "1.0.0",
873        serde_json::json!({}),
874        &[("index.js", "export const url = import.meta.url;")],
875    );
876    let dir = tempfile::tempdir().unwrap();
877    fs::write(
878        dir.path().join("main.ts"),
879        "const first = await import('npm:meta@1'); const second = await import(first.url); console.log(first === second, first.url.startsWith('file:'), first.url);",
880    )
881    .unwrap();
882    let out = success(&server.run_npm(dir.path(), &["run"], "main.ts"));
883    assert!(out.starts_with("true true file://"), "{out}");
884    assert!(out.contains(".avril"), "{out}");
885}
886
887#[test]
888fn version_scoped_grants_deny_uncovered_packages() {
889    let server = Registry::new();
890    server.publish_package(
891        "guarded",
892        "1.0.0",
893        serde_json::json!({}),
894        &[("index.js", "export default 1;")],
895    );
896    server.publish_package(
897        "other",
898        "1.0.0",
899        serde_json::json!({}),
900        &[("index.js", "export default 2;")],
901    );
902    let dir = tempfile::tempdir().unwrap();
903    fs::write(
904        dir.path().join("main.ts"),
905        "import 'npm:guarded@1'; import 'npm:other@1';",
906    )
907    .unwrap();
908    // Authority covers only the guarded package: the other fetch never happens.
909    let grant = format!("--allow-js-exec={}guarded/", server.url);
910    let output = server.run_npm(dir.path(), &["run", "-0", &grant], "main.ts");
911    assert!(!output.status.success());
912    assert!(
913        !server
914            .requests
915            .lock()
916            .unwrap()
917            .iter()
918            .any(|path| path.contains("other")),
919        "denied packages must not be fetched: {:?}",
920        server.requests.lock().unwrap()
921    );
922}
923
924#[test]
925fn file_grants_allow_exact_exports_but_deny_siblings() {
926    let server = Registry::new();
927    server.publish_package(
928        "files",
929        "1.0.0",
930        serde_json::json!({"exports": {".": "./index.js", "./secret": "./secret.js"}}),
931        &[
932            ("index.js", "export default 'public';"),
933            ("secret.js", "export default 'secret';"),
934        ],
935    );
936    let dir = tempfile::tempdir().unwrap();
937    let base = format!("{}files/1.0.0/", server.url);
938    let allow = format!("--allow-js-exec={}index.js", base);
939    let deny = format!("--deny-js-exec={}secret.js", base);
940    fs::write(
941        dir.path().join("main.ts"),
942        "import value from 'npm:files@1'; console.log(value);",
943    )
944    .unwrap();
945    assert_eq!(
946        success(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts")),
947        "public\n"
948    );
949    fs::write(
950        dir.path().join("main.ts"),
951        "import value from 'npm:files@1/secret'; console.log(value);",
952    )
953    .unwrap();
954    assert!(
955        failure(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts"))
956            .contains("PermissionDenied")
957    );
958}
959
960#[test]
961fn revocation_during_acquisition_denies_settled_imports() {
962    use std::sync::atomic::Ordering;
963    use std::time::{Duration, Instant};
964    let server = Registry::new();
965    server.publish_package(
966        "slow",
967        "1.0.0",
968        serde_json::json!({}),
969        &[("index.js", "export default 1;")],
970    );
971    server.hold_tarballs.store(true, Ordering::Relaxed);
972    let dir = tempfile::tempdir().unwrap();
973    let grant = format!("--allow-js-exec={}slow/", server.url);
974    fs::write(
975        dir.path().join("main.ts"),
976        "const pending = import('npm:slow@1'); await new Promise((resolve) => setTimeout(resolve, 200)); const {pledge} = await import('avril:std'); pledge({jsExec: false}); try { await pending; throw new Error('allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }",
977    )
978    .unwrap();
979    let child = std::process::Command::new(env!("CARGO_BIN_EXE_avril"))
980        .current_dir(dir.path())
981        .args(["run", "-0", grant.as_str()])
982        .arg(format!("--jsr-registry={}", server.url))
983        .arg(format!("--npm-registry={}", server.url))
984        .arg(format!(
985            "--cache-dir={}",
986            dir.path().join("cache").display()
987        ))
988        .arg("main.ts")
989        .stdout(std::process::Stdio::piped())
990        .stderr(std::process::Stdio::piped())
991        .spawn()
992        .unwrap();
993    let start = Instant::now();
994    loop {
995        if server
996            .requests
997            .lock()
998            .unwrap()
999            .iter()
1000            .any(|path| path.ends_with(".tgz"))
1001        {
1002            break;
1003        }
1004        assert!(
1005            start.elapsed() < Duration::from_secs(30),
1006            "tarball fetch never started"
1007        );
1008        std::thread::sleep(Duration::from_millis(10));
1009    }
1010    // The guest pledges at ~200ms; release the tarball later so the
1011    // post-fetch authority recheck deterministically observes it.
1012    std::thread::sleep(Duration::from_millis(500));
1013    server.hold_tarballs.store(false, Ordering::Relaxed);
1014    let output = child.wait_with_output().unwrap();
1015    assert_eq!(success(&output), "true\n");
1016}
1017
1018#[test]
1019fn add_pins_unversioned_npm_targets_and_caches_exports() {
1020    let server = Registry::new();
1021    server.publish_package(
1022        "tool",
1023        "1.0.0",
1024        serde_json::json!({"exports": {".": "./mod.js"}}),
1025        &[("mod.js", "export default 'tool';")],
1026    );
1027    server.publish_package(
1028        "tool",
1029        "1.1.0",
1030        serde_json::json!({"exports": {".": "./mod.js"}}),
1031        &[("mod.js", "export default 'tool2';")],
1032    );
1033    let dir = tempfile::tempdir().unwrap();
1034    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
1035    let output = server.run_npm(dir.path(), &["add", "npm:tool"], "");
1036    assert!(
1037        success(&output).contains("\"tool\": \"npm:tool@^1.1.0\""),
1038        "{output:?}"
1039    );
1040    let lock: serde_json::Value = fixture::read_lock(dir.path());
1041    assert_eq!(lock["npm"]["specifiers"]["npm:tool@^1.1.0"], "1.1.0");
1042}
1043
1044#[test]
1045fn add_keeps_tags_and_aliases_explicit() {
1046    let server = Registry::new();
1047    server.publish_package(
1048        "tool",
1049        "1.0.0",
1050        serde_json::json!({"exports": {".": "./mod.js", "./sub": "./sub.js"}}),
1051        &[
1052            ("mod.js", "export default 1;"),
1053            ("sub.js", "export default 2;"),
1054        ],
1055    );
1056    let dir = tempfile::tempdir().unwrap();
1057    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
1058    let output = server.run_npm(dir.path(), &["add", "npm:tool@latest"], "");
1059    assert!(
1060        success(&output).contains("\"tool\": \"npm:tool@latest\""),
1061        "{output:?}"
1062    );
1063    let output = server.run_npm(dir.path(), &["add", "sub", "npm:tool@1/sub"], "");
1064    assert!(
1065        success(&output).contains("\"sub\": \"npm:tool@1/sub\""),
1066        "{output:?}"
1067    );
1068}
1069
1070#[test]
1071fn vendor_covers_jsr_http_and_npm_side_by_side() {
1072    let server = Registry::new();
1073    server.package(
1074        "@test/value",
1075        "1.0.0",
1076        &[("mod.ts", "export const value = 'one';")],
1077        &[(".", "./mod.ts")],
1078    );
1079    server.publish_package(
1080        "tool",
1081        "1.0.0",
1082        serde_json::json!({}),
1083        &[("index.js", "export const value = 'tool';")],
1084    );
1085    let dir = tempfile::tempdir().unwrap();
1086    fs::write(
1087        dir.path().join("avril.conf"),
1088        "deps: ['jsr:@test/value@1', 'npm:tool@1']",
1089    )
1090    .unwrap();
1091    fs::write(
1092        dir.path().join("main.ts"),
1093        "import {value as a} from '@test/value'; import {value as b} from 'tool'; console.log(a, b);",
1094    )
1095    .unwrap();
1096    assert_eq!(
1097        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
1098        "one tool\n"
1099    );
1100    assert_eq!(
1101        success(&server.run_npm(dir.path(), &["vendor", "--out=vendor"], "main.ts")),
1102        ""
1103    );
1104    assert!(
1105        dir.path()
1106            .join("vendor")
1107            .join("@test/value/1.0.0/mod.ts")
1108            .is_file()
1109    );
1110    let npm_files: Vec<_> = walkdir(&dir.path().join("vendor").join("npm"))
1111        .into_iter()
1112        .filter(|path| path.extension().is_some_and(|ext| ext == "js"))
1113        .collect();
1114    assert!(!npm_files.is_empty(), "vendored npm files");
1115    assert!(
1116        npm_files
1117            .iter()
1118            .any(|path| path.to_string_lossy().contains("tool"))
1119    );
1120}
1121
1122fn walkdir(path: &std::path::Path) -> Vec<std::path::PathBuf> {
1123    let mut out = Vec::new();
1124    for entry in std::fs::read_dir(path).unwrap() {
1125        let entry = entry.unwrap().path();
1126        if entry.is_dir() {
1127            out.extend(walkdir(&entry));
1128        } else {
1129            out.push(entry);
1130        }
1131    }
1132    out
1133}
1134
1135#[test]
1136fn vendored_npm_reconstructs_offline_without_caches() {
1137    let server = Registry::new();
1138    server.publish_package(
1139        "vend",
1140        "1.0.0",
1141        serde_json::json!({}),
1142        &[("index.js", "export const value = 'vend';")],
1143    );
1144    let dir = tempfile::tempdir().unwrap();
1145    fs::write(dir.path().join("avril.conf"), "deps: ['npm:vend@1']").unwrap();
1146    fs::write(
1147        dir.path().join("main.ts"),
1148        "import {value} from 'vend'; console.log(value);",
1149    )
1150    .unwrap();
1151    assert_eq!(
1152        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
1153        "vend\n"
1154    );
1155    assert_eq!(
1156        success(&server.run_npm(dir.path(), &["vendor", "--out=vendor"], "main.ts")),
1157        ""
1158    );
1159    std::fs::remove_dir_all(dir.path().join(".avril")).unwrap();
1160    let before = server.requests.lock().unwrap().len();
1161    let run = server.run_npm(
1162        dir.path(),
1163        &["run", "--vendor=vendor", "--offline", "--frozen"],
1164        "main.ts",
1165    );
1166    assert_eq!(success(&run), "vend\n");
1167    assert_eq!(server.requests.lock().unwrap().len(), before);
1168}
1169
1170#[test]
1171fn file_grants_cover_exact_exports_and_nested_denials() {
1172    let server = Registry::new();
1173    server.publish_package(
1174        "files",
1175        "1.0.0",
1176        serde_json::json!({"exports": {".": "./index.js", "./nested": "./nested/deep.js"}}),
1177        &[
1178            ("index.js", "export {value} from './secret.js';"),
1179            ("secret.js", "export const value = 'secret';"),
1180            ("nested/deep.js", "export const value = 'deep';"),
1181        ],
1182    );
1183    let dir = tempfile::tempdir().unwrap();
1184    let base = format!("{}files/1.0.0/", server.url);
1185    let allow = format!("--allow-js-exec={}nested/deep.js", base);
1186    let deny = format!("--deny-js-exec={}secret.js", base);
1187    // The exact grant loads its own file; the root needs the denied
1188    // sibling through its nested import and fails.
1189    fs::write(
1190        dir.path().join("main.ts"),
1191        "import {value} from 'npm:files@1/nested'; console.log(value);",
1192    )
1193    .unwrap();
1194    assert_eq!(
1195        success(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts")),
1196        "deep\n"
1197    );
1198    fs::write(
1199        dir.path().join("main.ts"),
1200        "import {value} from 'npm:files@1'; console.log(value);",
1201    )
1202    .unwrap();
1203    assert!(
1204        failure(&server.run_npm(dir.path(), &["run", "-0", &allow, &deny], "main.ts"))
1205            .contains("PermissionDenied")
1206    );
1207}
1208
1209#[test]
1210fn revocation_mid_acquisition_denies_before_use() {
1211    use std::sync::atomic::Ordering;
1212    use std::time::{Duration, Instant};
1213    let server = Registry::new();
1214    server.publish_package(
1215        "slow",
1216        "1.0.0",
1217        serde_json::json!({}),
1218        &[("index.js", "export default 1;")],
1219    );
1220    server.hold_tarballs.store(true, Ordering::Relaxed);
1221    let dir = tempfile::tempdir().unwrap();
1222    let grant = format!("--allow-js-exec={}slow/", server.url);
1223    fs::write(
1224        dir.path().join("main.ts"),
1225        "const pending = import('npm:slow@1'); await new Promise((resolve) => setTimeout(resolve, 200)); const {pledge} = await import('avril:std'); pledge({jsExec: false}); try { await pending; throw new Error('allowed'); } catch (e) { console.log(e.message.includes('PermissionDenied')); }",
1226    )
1227    .unwrap();
1228    let child = std::process::Command::new(env!("CARGO_BIN_EXE_avril"))
1229        .current_dir(dir.path())
1230        .args(["run", "-0", grant.as_str()])
1231        .arg(format!("--jsr-registry={}", server.url))
1232        .arg(format!("--npm-registry={}", server.url))
1233        .arg(format!(
1234            "--cache-dir={}",
1235            dir.path().join("cache").display()
1236        ))
1237        .arg("main.ts")
1238        .stdout(std::process::Stdio::piped())
1239        .stderr(std::process::Stdio::piped())
1240        .spawn()
1241        .unwrap();
1242    let start = Instant::now();
1243    loop {
1244        if server
1245            .requests
1246            .lock()
1247            .unwrap()
1248            .iter()
1249            .any(|path| path.ends_with(".tgz"))
1250        {
1251            break;
1252        }
1253        assert!(
1254            start.elapsed() < Duration::from_secs(30),
1255            "tarball fetch never started"
1256        );
1257        std::thread::sleep(Duration::from_millis(10));
1258    }
1259    // The guest pledges at ~200ms; release later so the post-fetch
1260    // authority recheck deterministically observes the revocation.
1261    std::thread::sleep(Duration::from_millis(500));
1262    server.hold_tarballs.store(false, Ordering::Relaxed);
1263    let output = child.wait_with_output().unwrap();
1264    assert_eq!(success(&output), "true\n");
1265}
1266
1267#[test]
1268fn lock_override_keeps_project_root_for_npm_storage() {
1269    let server = Registry::new();
1270    server.publish_package(
1271        "rooted",
1272        "1.0.0",
1273        serde_json::json!({}),
1274        &[("index.js", "export default 1;")],
1275    );
1276    let dir = tempfile::tempdir().unwrap();
1277    fs::write(dir.path().join("avril.conf"), "deps: ['npm:rooted@1']").unwrap();
1278    fs::write(
1279        dir.path().join("main.ts"),
1280        "import 'npm:rooted@1'; console.log('ok');",
1281    )
1282    .unwrap();
1283    let elsewhere = tempfile::tempdir().unwrap();
1284    let output = std::process::Command::new(env!("CARGO_BIN_EXE_avril"))
1285        .current_dir(dir.path())
1286        .args(["run"])
1287        .arg(format!("--jsr-registry={}", server.url))
1288        .arg(format!("--npm-registry={}", server.url))
1289        .arg(format!(
1290            "--cache-dir={}",
1291            dir.path().join("cache").display()
1292        ))
1293        .arg(format!(
1294            "--lock={}",
1295            elsewhere.path().join("elsewhere.lock").display()
1296        ))
1297        .arg("main.ts")
1298        .output()
1299        .unwrap();
1300    assert_eq!(success(&output), "ok\n");
1301    assert!(dir.path().join(".avril").join("npm").is_dir());
1302    assert!(elsewhere.path().join("elsewhere.lock").is_file());
1303}
1304
1305#[test]
1306fn mapped_away_npm_literals_never_touch_the_registry() {
1307    let server = Registry::new();
1308    server.publish_package(
1309        "real",
1310        "1.0.0",
1311        serde_json::json!({}),
1312        &[("index.js", "export default 'real';")],
1313    );
1314    let dir = tempfile::tempdir().unwrap();
1315    fs::write(
1316        dir.path().join("avril.conf"),
1317        "imports: {'npm:blocked': './safe.ts'}",
1318    )
1319    .unwrap();
1320    fs::write(dir.path().join("safe.ts"), "export default 'safe';").unwrap();
1321    fs::write(
1322        dir.path().join("main.ts"),
1323        "import a from 'npm:blocked'; import b from 'npm:real@1'; console.log(a, b);",
1324    )
1325    .unwrap();
1326    assert_eq!(
1327        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
1328        "safe real\n"
1329    );
1330    assert!(
1331        !server
1332            .requests
1333            .lock()
1334            .unwrap()
1335            .iter()
1336            .any(|path| path.contains("blocked")),
1337        "mapped-away literals must not be fetched"
1338    );
1339}
1340
1341#[test]
1342fn dynamic_misses_do_not_poison_valid_imports() {
1343    let server = Registry::new();
1344    server.publish_package(
1345        "good",
1346        "1.0.0",
1347        serde_json::json!({}),
1348        &[("index.js", "export const value = 'good';")],
1349    );
1350    let dir = tempfile::tempdir().unwrap();
1351    fs::write(
1352        dir.path().join("main.ts"),
1353        "const [a, b] = await Promise.allSettled([import('npm:good@1'), import('npm:missing@9')]); console.log(a.status, b.status, a.value.value); const c = await import('npm:good@1'); console.log(c.value, a.value === c);",
1354    )
1355    .unwrap();
1356    assert_eq!(
1357        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
1358        "fulfilled rejected good\ngood true\n"
1359    );
1360}
1361
1362#[test]
1363fn update_selects_newer_versions_and_prune_keeps_the_closure() {
1364    let server = Registry::new();
1365    server.publish_package("up", "1.0.0", serde_json::json!({}), &[]);
1366    server.publish_package("stale", "1.0.0", serde_json::json!({}), &[]);
1367    let dir = tempfile::tempdir().unwrap();
1368    fs::write(
1369        dir.path().join("avril.conf"),
1370        "deps: ['npm:up@*', 'npm:stale@1']",
1371    )
1372    .unwrap();
1373    fs::write(
1374        dir.path().join("main.ts"),
1375        "import 'npm:up@*'; import 'npm:stale@1'; console.log('ok');",
1376    )
1377    .unwrap();
1378    assert_eq!(
1379        success(&server.run_npm(dir.path(), &["cache"], "main.ts")),
1380        ""
1381    );
1382    server.publish_package("up", "2.0.0", serde_json::json!({}), &[]);
1383    assert_eq!(
1384        success(&server.run_npm(dir.path(), &["update"], "main.ts")),
1385        ""
1386    );
1387    let lock: serde_json::Value = fixture::read_lock(dir.path());
1388    assert_eq!(lock["npm"]["specifiers"]["npm:up@*"], "2.0.0");
1389    fs::write(dir.path().join("avril.conf"), "deps: ['npm:up@*']").unwrap();
1390    fs::write(
1391        dir.path().join("main.ts"),
1392        "import 'npm:up@*'; console.log('ok');",
1393    )
1394    .unwrap();
1395    assert_eq!(
1396        success(&server.run_npm(dir.path(), &["update"], "main.ts")),
1397        ""
1398    );
1399    // Plain updates merge: the dropped declaration stays until pruned.
1400    let lock: serde_json::Value = fixture::read_lock(dir.path());
1401    assert!(lock["npm"]["specifiers"].get("npm:stale@1").is_some());
1402    assert_eq!(
1403        success(&server.run_npm(dir.path(), &["update", "--prune"], "main.ts")),
1404        ""
1405    );
1406    let lock: serde_json::Value = fixture::read_lock(dir.path());
1407    assert!(lock["npm"]["specifiers"].get("npm:stale@1").is_none());
1408    assert!(lock["npm"]["specifiers"].get("npm:up@*").is_some());
1409    assert!(
1410        lock["npm"]["packages"]
1411            .as_object()
1412            .unwrap()
1413            .keys()
1414            .all(|key| key.contains("|up|")),
1415        "prune drops the whole stale closure"
1416    );
1417}
1418
1419#[test]
1420fn includes_prepare_extra_roots() {
1421    let server = Registry::new();
1422    server.publish_package(
1423        "inc",
1424        "1.0.0",
1425        serde_json::json!({}),
1426        &[("index.js", "export const value = 'inc';")],
1427    );
1428    let dir = tempfile::tempdir().unwrap();
1429    fs::write(dir.path().join("main.ts"), "console.log('main');").unwrap();
1430    assert_eq!(
1431        success(&server.run_npm(dir.path(), &["run", "--include=npm:inc@1"], "main.ts")),
1432        "main\n"
1433    );
1434    assert!(
1435        server
1436            .requests
1437            .lock()
1438            .unwrap()
1439            .iter()
1440            .any(|path| path == "/inc"),
1441        "includes prepare their roots"
1442    );
1443    // Includes resolve from an explicit referrer too.
1444    fs::write(dir.path().join("other.ts"), "export const other = 1;").unwrap();
1445    assert_eq!(
1446        success(&server.run_npm(
1447            dir.path(),
1448            &[
1449                "run",
1450                "--include-from=./other.ts",
1451                "--include=npm:inc@1",
1452                "--include-from=.",
1453            ],
1454            "main.ts"
1455        )),
1456        "main\n"
1457    );
1458}
1459
1460#[test]
1461fn add_prefixes_map_every_esm_export() {
1462    let server = Registry::new();
1463    server.publish_package(
1464        "tool",
1465        "1.0.0",
1466        serde_json::json!({"exports": {
1467            ".": "./mod.js",
1468            "./sub": "./sub.js",
1469            "./data": "./data.json",
1470        }}),
1471        &[
1472            ("mod.js", "export default 1;"),
1473            ("sub.js", "export default 2;"),
1474            ("data.json", "{\"n\": 3}"),
1475        ],
1476    );
1477    let dir = tempfile::tempdir().unwrap();
1478    fs::write(dir.path().join("avril.conf"), "imports: {}").unwrap();
1479    let output = server.run_npm(dir.path(), &["add", "tool/", "npm:tool@1/"], "");
1480    let stdout = success(&output);
1481    assert!(stdout.contains("\"tool/\": \"npm:tool@1/\""), "{stdout}");
1482    // Adding the prefix warms every ESM export, so the subpath runs offline.
1483    fs::write(
1484        dir.path().join("main.ts"),
1485        "import sub from 'tool/sub'; console.log(sub);",
1486    )
1487    .unwrap();
1488    let before = server.requests.lock().unwrap().len();
1489    let run = server.run_npm(dir.path(), &["run", "--offline"], "main.ts");
1490    assert_eq!(success(&run), "2\n");
1491    assert_eq!(server.requests.lock().unwrap().len(), before);
1492}
1493
1494#[test]
1495fn declared_but_unused_dependencies_replay_frozen() {
1496    let server = Registry::new();
1497    server.publish_package("used", "1.0.0", serde_json::json!({}), &[]);
1498    server.publish_package("unused", "1.0.0", serde_json::json!({}), &[]);
1499    let dir = tempfile::tempdir().unwrap();
1500    fs::write(
1501        dir.path().join("avril.conf"),
1502        "deps: ['npm:used@1', 'npm:unused@1']",
1503    )
1504    .unwrap();
1505    fs::write(
1506        dir.path().join("main.ts"),
1507        "import 'npm:used@1'; console.log('ok');",
1508    )
1509    .unwrap();
1510    assert_eq!(
1511        success(&server.run_npm(dir.path(), &["cache"], "main.ts")),
1512        ""
1513    );
1514    // Unused declarations stay replayable without network or mutation.
1515    let before = server.requests.lock().unwrap().len();
1516    let run = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts");
1517    assert_eq!(success(&run), "ok\n");
1518    assert_eq!(server.requests.lock().unwrap().len(), before);
1519}
1520
1521#[test]
1522fn relocated_trees_run_offline_without_absolute_paths() {
1523    let server = Registry::new();
1524    server.publish_package(
1525        "reloc",
1526        "1.0.0",
1527        serde_json::json!({}),
1528        &[("index.js", "export const value = 'reloc';")],
1529    );
1530    let dir = tempfile::tempdir().unwrap();
1531    fs::write(dir.path().join("avril.conf"), "deps: ['npm:reloc@1']").unwrap();
1532    fs::write(
1533        dir.path().join("main.ts"),
1534        "import {value} from 'npm:reloc@1'; console.log(value);",
1535    )
1536    .unwrap();
1537    assert_eq!(
1538        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
1539        "reloc\n"
1540    );
1541    let lock = fs::read_to_string(dir.path().join("avril.lock")).unwrap();
1542    assert!(
1543        !lock.contains(dir.path().to_string_lossy().as_ref()),
1544        "locks stay relocatable"
1545    );
1546    let moved = tempfile::tempdir().unwrap();
1547    copy_tree(dir.path(), moved.path());
1548    let before = server.requests.lock().unwrap().len();
1549    let run = server.run_npm(moved.path(), &["run", "--offline", "--frozen"], "main.ts");
1550    assert_eq!(success(&run), "reloc\n");
1551    assert_eq!(server.requests.lock().unwrap().len(), before);
1552}
1553
1554fn copy_tree(source: &std::path::Path, destination: &std::path::Path) {
1555    for entry in std::fs::read_dir(source).unwrap() {
1556        let entry = entry.unwrap();
1557        let target = destination.join(entry.file_name());
1558        if entry.file_type().unwrap().is_dir() {
1559            std::fs::create_dir_all(&target).unwrap();
1560            copy_tree(&entry.path(), &target);
1561        } else {
1562            std::fs::copy(entry.path(), target).unwrap();
1563        }
1564    }
1565}
1566
1567#[test]
1568fn add_warms_string_conditional_and_pattern_exports() {
1569    let server = Registry::new();
1570    server.publish_package(
1571        "forms",
1572        "1.0.0",
1573        serde_json::json!({"exports": {
1574            ".": {"node": "./n.cjs", "import": "./index.js"},
1575            "./feature/*": "./dist/*.js",
1576        }}),
1577        &[
1578            ("index.js", "export default 'root';"),
1579            ("n.cjs", "module.exports = 'node';"),
1580            ("dist/thing.js", "export default 'thing';"),
1581        ],
1582    );
1583    let dir = tempfile::tempdir().unwrap();
1584    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
1585    let output = server.run_npm(dir.path(), &["add", "npm:forms"], "");
1586    assert!(
1587        success(&output).contains("\"forms\": \"npm:forms@^1.0.0\""),
1588        "{output:?}"
1589    );
1590    fs::write(
1591        dir.path().join("main.ts"),
1592        "import root from 'forms'; import thing from 'forms/feature/thing'; console.log(root, thing);",
1593    )
1594    .unwrap();
1595    let before = server.requests.lock().unwrap().len();
1596    let run = server.run_npm(dir.path(), &["run", "--offline"], "main.ts");
1597    assert_eq!(success(&run), "root thing\n");
1598    assert_eq!(server.requests.lock().unwrap().len(), before);
1599    // A nested prefix warms through its own scope.
1600    let scoped = tempfile::tempdir().unwrap();
1601    fs::write(scoped.path().join("avril.conf"), "imports: {}").unwrap();
1602    let output = server.run_npm(scoped.path(), &["add", "feat/", "npm:forms@1/feature/"], "");
1603    assert!(
1604        success(&output).contains("\"feat/\": \"npm:forms@1/feature/\""),
1605        "{output:?}"
1606    );
1607    fs::write(
1608        scoped.path().join("main.ts"),
1609        "import thing from 'feat/thing'; console.log(thing);",
1610    )
1611    .unwrap();
1612    let before = server.requests.lock().unwrap().len();
1613    let run = server.run_npm(scoped.path(), &["run", "--offline"], "main.ts");
1614    assert_eq!(success(&run), "thing\n");
1615    assert_eq!(server.requests.lock().unwrap().len(), before);
1616}
1617
1618#[test]
1619fn prefix_add_ignores_broken_exports_outside_its_scope() {
1620    let server = Registry::new();
1621    server.publish_package(
1622        "sides",
1623        "1.0.0",
1624        serde_json::json!({"exports": {
1625            ".": "./index.js",
1626            "./good": "./good.js",
1627            "./bad": "./bad.js",
1628        }}),
1629        &[
1630            ("index.js", "export default 0;"),
1631            ("good.js", "export default 'good';"),
1632            ("bad.js", "this is not valid javascript (((("),
1633        ],
1634    );
1635    let dir = tempfile::tempdir().unwrap();
1636    fs::write(dir.path().join("avril.conf"), "imports: {}").unwrap();
1637    // Warming only the good prefix never parses the broken sibling.
1638    let output = server.run_npm(dir.path(), &["add", "good", "npm:sides@1/good"], "");
1639    assert!(
1640        success(&output).contains("\"good\": \"npm:sides@1/good\""),
1641        "{output:?}"
1642    );
1643    fs::write(
1644        dir.path().join("main.ts"),
1645        "import good from 'good'; console.log(good);",
1646    )
1647    .unwrap();
1648    assert_eq!(
1649        success(&server.run_npm(dir.path(), &["run", "--offline"], "main.ts")),
1650        "good\n"
1651    );
1652    // The whole package still fails loudly: broken files are not skipped.
1653    let output = server.run_npm(dir.path(), &["add", "sides/", "npm:sides@1/"], "");
1654    assert!(!output.status.success());
1655}
1656
1657#[test]
1658fn failed_caches_leave_no_npm_roots_or_artifacts() {
1659    let server = Registry::new();
1660    server.publish_package(
1661        "broken",
1662        "1.0.0",
1663        serde_json::json!({"dependencies": {"inner": "^1.0.0"}}),
1664        &[("index.js", "export * from 'inner';")],
1665    );
1666    server.publish_package(
1667        "inner",
1668        "1.0.0",
1669        serde_json::json!({}),
1670        &[("index.js", "export default ((((")],
1671    );
1672    server.publish_package(
1673        "uses-node",
1674        "1.0.0",
1675        serde_json::json!({}),
1676        &[("index.js", "import 'node:fs'; export default 1;")],
1677    );
1678    server.publish_package("fine", "1.0.0", serde_json::json!({}), &[]);
1679    for (name, spec) in [
1680        ("bad-syntax", "npm:broken@1"),
1681        ("node-builtin", "npm:uses-node@1"),
1682    ] {
1683        let dir = tempfile::tempdir().unwrap();
1684        fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
1685        fs::write(
1686            dir.path().join("main.ts"),
1687            format!("import '{spec}'; console.log('unreachable');"),
1688        )
1689        .unwrap();
1690        let output = server.run_npm(dir.path(), &["cache"], "main.ts");
1691        assert!(!output.status.success(), "{name}");
1692        let lock = fs::read_to_string(dir.path().join("avril.lock")).unwrap_or_default();
1693        assert!(
1694            !lock.contains("broken") && !lock.contains("inner") && !lock.contains("uses-node"),
1695            "{name}: {lock}"
1696        );
1697    }
1698    // The unrelated package still caches cleanly afterwards.
1699    let dir = tempfile::tempdir().unwrap();
1700    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
1701    fs::write(
1702        dir.path().join("main.ts"),
1703        "import 'npm:fine@1'; console.log('fine');",
1704    )
1705    .unwrap();
1706    assert_eq!(
1707        success(&server.run_npm(dir.path(), &["cache"], "main.ts")),
1708        ""
1709    );
1710    let lock: serde_json::Value = fixture::read_lock(dir.path());
1711    assert_eq!(lock["npm"]["specifiers"]["npm:fine@1"], "1.0.0");
1712    assert!(lock["npm"]["specifiers"].as_object().unwrap().len() == 1);
1713}
1714
1715#[test]
1716fn peer_contexts_keep_separate_roots_across_rewrites() {
1717    let server = Registry::new();
1718    server.publish_package(
1719        "plug",
1720        "1.0.0",
1721        serde_json::json!({"peerDependencies": {"host": "*"}}),
1722        &[("index.js", "export {version} from 'host';")],
1723    );
1724    server.publish_package(
1725        "host",
1726        "1.0.0",
1727        serde_json::json!({}),
1728        &[("index.js", "export default ((((")],
1729    );
1730    server.publish_package(
1731        "host",
1732        "2.0.0",
1733        serde_json::json!({}),
1734        &[("index.js", "export const version = 'h2';")],
1735    );
1736    let dir = tempfile::tempdir().unwrap();
1737    fs::write(
1738        dir.path().join("avril.conf"),
1739        "deps: ['npm:plug@1', 'npm:host@1']",
1740    )
1741    .unwrap();
1742    fs::write(
1743        dir.path().join("main.ts"),
1744        "import {version} from 'npm:plug@1'; console.log(version);",
1745    )
1746    .unwrap();
1747    // The host@1 peer context resolves but never completes: nothing is saved.
1748    let output = server.run_npm(dir.path(), &["cache"], "main.ts");
1749    assert!(!output.status.success());
1750    let lock = fs::read_to_string(dir.path().join("avril.lock")).unwrap_or_default();
1751    assert!(!lock.contains("plug") && !lock.contains("host"), "{lock}");
1752    // The host@2 context then caches; only its roots land in the lock.
1753    fs::write(
1754        dir.path().join("avril.conf"),
1755        "deps: ['npm:plug@1', 'npm:host@2']",
1756    )
1757    .unwrap();
1758    assert_eq!(
1759        success(&server.run_npm(dir.path(), &["cache"], "main.ts")),
1760        ""
1761    );
1762    let lock: serde_json::Value = fixture::read_lock(dir.path());
1763    let roots = lock["npm"]["roots"].as_object().unwrap();
1764    assert!(
1765        roots.keys().all(|key| !key.contains("|host|1.0.0")),
1766        "{roots:?}"
1767    );
1768    assert_eq!(
1769        success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")),
1770        "h2\n"
1771    );
1772}
1773
1774#[test]
1775fn two_add_invocations_stay_frozen_compatible() {
1776    let server = Registry::new();
1777    server.publish_package(
1778        "plug",
1779        "1.0.0",
1780        serde_json::json!({"peerDependencies": {"host": "^1.0.0"}}),
1781        &[("index.js", "export {version} from 'host';")],
1782    );
1783    server.publish_package(
1784        "host",
1785        "1.0.0",
1786        serde_json::json!({}),
1787        &[("index.js", "export const version = 'h1';")],
1788    );
1789    let dir = tempfile::tempdir().unwrap();
1790    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
1791    assert!(success(&server.run_npm(dir.path(), &["add", "npm:plug@1"], "")).contains("plug"));
1792    assert!(success(&server.run_npm(dir.path(), &["add", "npm:host@1"], "")).contains("host"));
1793    fs::write(
1794        dir.path().join("main.ts"),
1795        "import {version} from 'plug'; console.log(version);",
1796    )
1797    .unwrap();
1798    // Each add cached its config context, so the joint roots are pinned.
1799    let before = server.requests.lock().unwrap().len();
1800    let run = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts");
1801    assert_eq!(success(&run), "h1\n");
1802    assert_eq!(server.requests.lock().unwrap().len(), before);
1803}
1804
1805#[test]
1806fn dynamic_reimports_share_namespaces_when_frozen() {
1807    let server = Registry::new();
1808    server.publish_package(
1809        "dyn",
1810        "1.0.0",
1811        serde_json::json!({}),
1812        &[("index.js", "export const value = 'dyn';")],
1813    );
1814    let dir = tempfile::tempdir().unwrap();
1815    fs::write(
1816        dir.path().join("main.ts"),
1817        "import {value} from 'npm:dyn@1'; const again = await import('npm:dyn@1'); console.log(value, again.value, again === (await import('npm:dyn@1')));",
1818    )
1819    .unwrap();
1820    assert_eq!(
1821        success(&server.run_npm(dir.path(), &["cache"], "main.ts")),
1822        ""
1823    );
1824    let before = server.requests.lock().unwrap().len();
1825    let run = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts");
1826    assert_eq!(success(&run), "dyn dyn true\n");
1827    assert_eq!(server.requests.lock().unwrap().len(), before);
1828}
1829
1830#[test]
1831fn sibling_peer_contexts_keep_distinct_host_namespaces() {
1832    let server = Registry::new();
1833    // Each host instance counts its own evaluation: sharing one instance
1834    // yields equal generations, while distinct peer environments diverge.
1835    server.publish_package(
1836        "plug",
1837        "1.0.0",
1838        serde_json::json!({"peerDependencies": {"host": "*"}}),
1839        &[("index.js", "export {generation as via} from 'host';")],
1840    );
1841    server.publish_package(
1842        "host",
1843        "1.0.0",
1844        serde_json::json!({"peerDependencies": {"flavor": "*"}, "peerDependenciesMeta": {"flavor": {"optional": true}}}),
1845        &[
1846            (
1847                "index.js",
1848                "globalThis.__hostEval = (globalThis.__hostEval ?? 0) + 1; export const generation = globalThis.__hostEval;",
1849            ),
1850        ],
1851    );
1852    // Optional peers absent from a consumer stay absent: no fetch, no error.
1853    server.publish_package(
1854        "flavor",
1855        "1.0.0",
1856        serde_json::json!({}),
1857        &[("index.js", "export const flavor = 'f';")],
1858    );
1859    for (cons, deps) in [
1860        (
1861            "cons-a",
1862            serde_json::json!({"dependencies": {"plug": "^1.0.0", "host": "^1.0.0", "flavor": "^1.0.0"}}),
1863        ),
1864        (
1865            "cons-b",
1866            serde_json::json!({"dependencies": {"plug": "^1.0.0", "host": "^1.0.0"}}),
1867        ),
1868    ] {
1869        server.publish_package(
1870            cons,
1871            "1.0.0",
1872            deps,
1873            &[(
1874                "index.js",
1875                "export {generation as direct} from 'host'; export {via as viaPlug} from 'plug';",
1876            )],
1877        );
1878    }
1879    let dir = tempfile::tempdir().unwrap();
1880    fs::write(
1881        dir.path().join("avril.conf"),
1882        "deps: ['npm:cons-a@1', 'npm:cons-b@1']",
1883    )
1884    .unwrap();
1885    fs::write(
1886        dir.path().join("main.ts"),
1887        "import {direct as aDirect, viaPlug as aVia} from 'cons-a'; import {direct as bDirect, viaPlug as bVia} from 'cons-b'; console.log(aDirect === aVia, bDirect === bVia, aDirect === bDirect);",
1888    )
1889    .unwrap();
1890    assert_eq!(
1891        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
1892        "true true false\n"
1893    );
1894    let lock = fixture::read_lock(dir.path());
1895    let default_registry = fs::read_dir(dir.path().join(".avril/npm/default"))
1896        .unwrap()
1897        .next()
1898        .unwrap()
1899        .unwrap()
1900        .path();
1901    let mut peer_destinations = Vec::new();
1902    for (instance, pin) in lock["npm"]["instances"].as_object().unwrap() {
1903        let package = pin["package"].as_str().unwrap();
1904        if instance == package || !instance.starts_with("p-") {
1905            continue;
1906        }
1907        let mut parts = package.split('|');
1908        let _registry = parts.next().unwrap();
1909        let name = parts.next().unwrap();
1910        let version = parts.next().unwrap();
1911        let source = default_registry.join(name).join(version);
1912        let destination = dir
1913            .path()
1914            .join(".avril/npm")
1915            .join(instance)
1916            .join(default_registry.file_name().unwrap())
1917            .join(name)
1918            .join(version);
1919        assert!(destination.join("package.json").is_file());
1920        fs::remove_dir_all(&destination).unwrap();
1921        fs::create_dir_all(&destination).unwrap();
1922        copy_tree(&source, &destination);
1923        peer_destinations.push(destination);
1924    }
1925    assert_eq!(
1926        success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")),
1927        "true true false\n"
1928    );
1929    for entry in fs::read_dir(dir.path().join(".avril/npm")).unwrap() {
1930        let entry = entry.unwrap();
1931        if entry.file_name().to_string_lossy().starts_with("p-") {
1932            fs::remove_dir_all(entry.path()).unwrap();
1933        }
1934    }
1935    assert_eq!(
1936        success(&server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts")),
1937        "true true false\n"
1938    );
1939    assert!(
1940        peer_destinations
1941            .iter()
1942            .all(|destination| destination.join("package.json").is_file())
1943    );
1944}
1945
1946#[test]
1947fn peer_instance_asset_urls_use_real_paths_and_read_authority() {
1948    let server = Registry::new();
1949    server.publish_package("asset-host", "1.0.0", serde_json::json!({}), &[]);
1950    server.publish_package(
1951        "asset-plugin",
1952        "1.0.0",
1953        serde_json::json!({"peerDependencies": {"asset-host": "*"}}),
1954        &[
1955            (
1956                "index.js",
1957                "import {readTextFile} from 'avril:fs'; export const asset = await readTextFile(new URL('./asset.txt', import.meta.url));",
1958            ),
1959            ("asset.txt", "peer asset"),
1960        ],
1961    );
1962    server.publish_package(
1963        "asset-consumer",
1964        "1.0.0",
1965        serde_json::json!({"dependencies": {"asset-host": "^1.0.0", "asset-plugin": "^1.0.0"}}),
1966        &[("index.js", "export {asset} from 'asset-plugin';")],
1967    );
1968    let dir = tempfile::tempdir().unwrap();
1969    fs::write(
1970        dir.path().join("avril.conf"),
1971        "deps: ['npm:asset-consumer@1']",
1972    )
1973    .unwrap();
1974    fs::write(
1975        dir.path().join("main.ts"),
1976        "import {asset} from 'asset-consumer'; console.log(asset);",
1977    )
1978    .unwrap();
1979    assert_eq!(
1980        success(&server.run_npm(dir.path(), &["run", "--allow-read=.avril/npm"], "main.ts")),
1981        "peer asset\n"
1982    );
1983    let denied = server.run_npm(
1984        dir.path(),
1985        &["run", "--offline", "--frozen", "--deny-read=.avril/npm"],
1986        "main.ts",
1987    );
1988    assert!(failure(&denied).contains("PermissionDenied: read"));
1989    let lock = fixture::read_lock(dir.path());
1990    let (instance, package) = lock["npm"]["instances"]
1991        .as_object()
1992        .unwrap()
1993        .iter()
1994        .find_map(|(instance, pin)| {
1995            let package = pin["package"].as_str()?;
1996            (package.contains("|asset-plugin|1.0.0") && instance.starts_with("p-"))
1997                .then_some((instance, package))
1998        })
1999        .unwrap();
2000    let registry_dir = fs::read_dir(dir.path().join(".avril/npm/default"))
2001        .unwrap()
2002        .next()
2003        .unwrap()
2004        .unwrap()
2005        .path();
2006    let mut parts = package.split('|');
2007    let _registry = parts.next().unwrap();
2008    let name = parts.next().unwrap();
2009    let version = parts.next().unwrap();
2010    let asset = dir
2011        .path()
2012        .join(".avril/npm")
2013        .join(instance)
2014        .join(registry_dir.file_name().unwrap())
2015        .join(name)
2016        .join(version)
2017        .join("asset.txt");
2018    assert_eq!(fs::read_to_string(&asset).unwrap(), "peer asset");
2019    let grant = format!(
2020        "--allow-read={}",
2021        asset.strip_prefix(dir.path()).unwrap().display()
2022    );
2023    assert_eq!(
2024        success(&server.run_npm(
2025            dir.path(),
2026            &["run", "--offline", "--frozen", &grant],
2027            "main.ts"
2028        )),
2029        "peer asset\n"
2030    );
2031}
2032
2033#[test]
2034fn sibling_roots_share_one_host_instance() {
2035    let server = Registry::new();
2036    for version in ["1.0.0", "2.0.0"] {
2037        server.publish_package(
2038            "x",
2039            version,
2040            serde_json::json!({}),
2041            &[("index.js", &format!("export const x = '{version}';"))],
2042        );
2043    }
2044    server.publish_package(
2045        "host",
2046        "1.0.0",
2047        serde_json::json!({"peerDependencies": {"x": "*"}}),
2048        &[
2049            (
2050                "index.js",
2051                "globalThis.__hostCount = (globalThis.__hostCount ?? 0) + 1; export const count = globalThis.__hostCount; export {x} from 'x';",
2052            ),
2053        ],
2054    );
2055    server.publish_package(
2056        "plugin",
2057        "1.0.0",
2058        serde_json::json!({"peerDependencies": {"host": "*"}, "dependencies": {"x": "^2.0.0"}}),
2059        &[(
2060            "index.js",
2061            "export {count as via} from 'host'; export {x as own} from 'x';",
2062        )],
2063    );
2064    let dir = tempfile::tempdir().unwrap();
2065    // host and x@1 are selected alongside plugin: plugin's peer host must
2066    // bind that exact host instance (x1) instead of reinstantiating host
2067    // under its own x2 dependency.
2068    fs::write(
2069        dir.path().join("avril.conf"),
2070        "deps: ['npm:host@1', 'npm:plugin@1', 'npm:x@1']",
2071    )
2072    .unwrap();
2073    fs::write(
2074        dir.path().join("main.ts"),
2075        "import {count as direct} from 'host'; import {via, own} from 'plugin'; console.log(direct, via, own);",
2076    )
2077    .unwrap();
2078    assert_eq!(
2079        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
2080        "1 1 2.0.0\n"
2081    );
2082}
2083
2084#[test]
2085fn add_reports_progress_phases_and_downloads() {
2086    let server = Registry::new();
2087    server.publish_package(
2088        "tool",
2089        "1.0.0",
2090        serde_json::json!({"exports": {".": "./mod.js"}}),
2091        &[("mod.js", "export default 1;")],
2092    );
2093    server.publish_package(
2094        "tool",
2095        "1.1.0",
2096        serde_json::json!({"exports": {".": "./mod.js"}}),
2097        &[("mod.js", "export default 2;")],
2098    );
2099    server.package(
2100        "@test/pkg",
2101        "1.0.0",
2102        &[("mod.ts", "export default 1;")],
2103        &[(".", "./mod.ts")],
2104    );
2105    let dir = tempfile::tempdir().unwrap();
2106    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2107    let output = server.run_npm(dir.path(), &["add", "npm:tool"], "");
2108    let stdout = success(&output);
2109    assert!(
2110        stdout.contains("\"tool\": \"npm:tool@^1.1.0\""),
2111        "{output:?}"
2112    );
2113    assert!(
2114        !stdout.contains("Selecting"),
2115        "stdout keeps only the mapping: {stdout:?}"
2116    );
2117    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2118    for needle in ["Selecting", "Caching", "tool"] {
2119        assert!(
2120            stderr.contains(needle),
2121            "stderr must show {needle}: {stderr:?}"
2122        );
2123    }
2124    assert!(
2125        stderr.contains("Fetching") || stderr.contains("Downloading"),
2126        "stderr must identify package downloads: {stderr:?}"
2127    );
2128    let dir = tempfile::tempdir().unwrap();
2129    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2130    let output = server.run_npm(dir.path(), &["add", "jsr:@test/pkg"], "");
2131    let stdout = success(&output);
2132    assert!(stdout.contains("jsr:@test/pkg@^1.0.0"), "{output:?}");
2133    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2134    for needle in ["Selecting", "Caching"] {
2135        assert!(
2136            stderr.contains(needle),
2137            "jsr add must show {needle}: {stderr:?}"
2138        );
2139    }
2140}
2141
2142#[test]
2143fn add_failure_keeps_prior_progress_and_error() {
2144    let server = Registry::new();
2145    let dir = tempfile::tempdir().unwrap();
2146    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2147    let output = server.run_npm(dir.path(), &["add", "npm:missing"], "");
2148    let stderr = failure(&output);
2149    assert!(
2150        stderr.contains("Selecting"),
2151        "prior progress must remain: {stderr:?}"
2152    );
2153    assert!(
2154        stderr.contains("404"),
2155        "registry failure must be preserved: {stderr:?}"
2156    );
2157}
2158
2159#[test]
2160fn add_warm_offline_does_not_claim_downloads() {
2161    let server = Registry::new();
2162    server.publish_package(
2163        "tool",
2164        "1.0.0",
2165        serde_json::json!({}),
2166        &[("index.js", "export default 1;")],
2167    );
2168    let dir = tempfile::tempdir().unwrap();
2169    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2170    let output = server.run_npm(dir.path(), &["add", "npm:tool"], "");
2171    assert!(output.status.success());
2172    let first_stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2173    assert!(
2174        first_stderr.contains("Downloading") || first_stderr.contains("Fetching"),
2175        "first add must show network work: {first_stderr:?}"
2176    );
2177    let output = server.run_npm(dir.path(), &["add", "--offline", "npm:tool"], "");
2178    assert!(
2179        output.status.success(),
2180        "{}",
2181        String::from_utf8_lossy(&output.stderr)
2182    );
2183    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2184    assert!(
2185        !stderr.contains("Downloading") && !stderr.contains("Fetching"),
2186        "warm offline add must not claim network downloads: {stderr:?}"
2187    );
2188}
2189
2190#[test]
2191fn other_commands_stay_silent_without_progress() {
2192    let server = Registry::new();
2193    server.publish_package(
2194        "tool",
2195        "1.0.0",
2196        serde_json::json!({}),
2197        &[("index.js", "export default 1;")],
2198    );
2199    let dir = tempfile::tempdir().unwrap();
2200    fs::write(dir.path().join("avril.conf"), "deps: ['npm:tool@1']").unwrap();
2201    fs::write(
2202        dir.path().join("main.ts"),
2203        "import value from 'tool'; console.log(value);",
2204    )
2205    .unwrap();
2206    let output = server.run_npm(dir.path(), &["run"], "main.ts");
2207    assert_eq!(success(&output), "1\n");
2208    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2209    for needle in [
2210        "Selecting",
2211        "Fetching",
2212        "Downloading",
2213        "Materialized",
2214        "Caching",
2215    ] {
2216        assert!(!stderr.contains(needle), "run must stay silent: {stderr:?}");
2217    }
2218}
2219
2220#[test]
2221fn compact_pin_end_to_end() {
2222    let server = Registry::new();
2223    server.publish_package(
2224        "compact",
2225        "1.0.0",
2226        serde_json::json!({}),
2227        &[("index.js", "export default 1;")],
2228    );
2229    let dir = tempfile::tempdir().unwrap();
2230    fs::write(dir.path().join("avril.conf"), "deps: ['npm:compact@1']").unwrap();
2231    fs::write(
2232        dir.path().join("main.ts"),
2233        "import value from 'compact'; console.log(value);",
2234    )
2235    .unwrap();
2236    assert_eq!(
2237        success(&server.run_npm(dir.path(), &["run"], "main.ts")),
2238        "1\n"
2239    );
2240    let lock: serde_json::Value = fixture::read_lock(dir.path());
2241    let pins = lock["npm"]["packages"].as_object().unwrap();
2242    assert_eq!(pins.len(), 1);
2243    let pin = pins.values().next().unwrap();
2244    assert!(
2245        pin["manifest"]
2246            .as_str()
2247            .is_some_and(|digest| digest.starts_with("sha256-")),
2248        "{pin}"
2249    );
2250    assert!(pin.get("files").is_none(), "{pin}");
2251}
2252
2253#[test]
2254fn add_selects_before_fetching_broken_archives() {
2255    let server = Registry::new();
2256    server.publish_package(
2257        "tool",
2258        "1.0.0",
2259        serde_json::json!({"exports": {".": "./mod.js"}}),
2260        &[("mod.js", "export default 1;")],
2261    );
2262    {
2263        let mut files = server.files.lock().unwrap();
2264        let tarball = files
2265            .keys()
2266            .find(|path| path.ends_with(".tgz"))
2267            .unwrap()
2268            .clone();
2269        files.insert(tarball, b"not gzip".to_vec());
2270    }
2271    let dir = tempfile::tempdir().unwrap();
2272    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2273    let output = server.run_npm(dir.path(), &["add", "npm:tool"], "");
2274    assert!(!output.status.success());
2275    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
2276    assert!(
2277        stdout.contains("npm:tool@^1.0.0"),
2278        "config must be reported: {output:?}"
2279    );
2280    let config = fs::read_to_string(dir.path().join("avril.conf")).unwrap();
2281    assert!(
2282        config.contains("npm:tool@^1.0.0"),
2283        "caret must be written: {config:?}"
2284    );
2285    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2286    assert!(
2287        stderr.contains("Selecting"),
2288        "selection must precede warming: {stderr:?}"
2289    );
2290}
2291
2292#[test]
2293fn add_shares_packument_between_selection_and_warming() {
2294    let server = Registry::new();
2295    server.publish_package(
2296        "tool",
2297        "1.0.0",
2298        serde_json::json!({"exports": {".": "./mod.js"}}),
2299        &[("mod.js", "export default 1;")],
2300    );
2301    server.publish_package(
2302        "tool",
2303        "1.1.0",
2304        serde_json::json!({"exports": {".": "./mod.js"}}),
2305        &[("mod.js", "export default 2;")],
2306    );
2307    let dir = tempfile::tempdir().unwrap();
2308    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2309    let output = server.run_npm(dir.path(), &["add", "npm:tool"], "");
2310    assert!(success(&output).contains("npm:tool@^1.1.0"), "{output:?}");
2311    let lock: serde_json::Value = fixture::read_lock(dir.path());
2312    assert_eq!(lock["npm"]["specifiers"]["npm:tool@^1.1.0"], "1.1.0");
2313    let packuments = server
2314        .requests
2315        .lock()
2316        .unwrap()
2317        .iter()
2318        .filter(|path| path.as_str() == "/tool")
2319        .count();
2320    assert_eq!(packuments, 1, "selection and warming must share one fetch");
2321}
2322
2323#[test]
2324fn add_warns_and_skips_node_builtin_edges() {
2325    let server = Registry::new();
2326    server.publish_package(
2327        "envtool",
2328        "1.0.0",
2329        serde_json::json!({"exports": {
2330            ".": "./mod.js",
2331            "./other": "./other.js",
2332            "./sibling": "./sibling.js",
2333        }}),
2334        &[
2335            (
2336                "mod.js",
2337                "import cp from 'node:child_process';\nimport {sibling} from './sibling.js';\nexport default [cp, sibling];",
2338            ),
2339            (
2340                "other.js",
2341                "import fs from 'node:fs';\nexport const lazy = await import('node:fs');\nexport default fs;",
2342            ),
2343            ("sibling.js", "export const sibling = 1;"),
2344        ],
2345    );
2346    let dir = tempfile::tempdir().unwrap();
2347    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2348    let output = server.run_npm(dir.path(), &["add", "npm:envtool"], "");
2349    assert!(
2350        success(&output).contains("\"envtool\": \"npm:envtool@^1.0.0\""),
2351        "{output:?}"
2352    );
2353    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2354    assert!(stderr.contains("Warning"), "{stderr:?}");
2355    assert!(stderr.contains("node:child_process"), "{stderr:?}");
2356    assert_eq!(stderr.matches("node:fs").count(), 1, "{stderr:?}");
2357    assert_eq!(
2358        stderr.matches("node:child_process").count(),
2359        1,
2360        "{stderr:?}"
2361    );
2362    let lock: serde_json::Value = fixture::read_lock(dir.path());
2363    assert_eq!(lock["npm"]["specifiers"]["npm:envtool@^1.0.0"], "1.0.0");
2364    fs::write(
2365        dir.path().join("main.ts"),
2366        "import {sibling} from 'envtool/sibling'; console.log(sibling);",
2367    )
2368    .unwrap();
2369    let output = server.run_npm(dir.path(), &["run", "--offline", "--frozen"], "main.ts");
2370    assert_eq!(success(&output), "1\n");
2371}
2372
2373#[test]
2374fn add_still_fails_on_broken_edges_despite_builtin_warnings() {
2375    let server = Registry::new();
2376    server.publish_package(
2377        "envbad",
2378        "1.0.0",
2379        serde_json::json!({}),
2380        &[(
2381            "index.js",
2382            "import 'node:fs';\nimport './nope.js';\nexport default 1;",
2383        )],
2384    );
2385    server.publish_package(
2386        "needsdep",
2387        "1.0.0",
2388        serde_json::json!({"dependencies": {"gone": "^1.0.0"}}),
2389        &[("index.js", "export default 1;")],
2390    );
2391    server.publish_package(
2392        "envsyntax",
2393        "1.0.0",
2394        serde_json::json!({}),
2395        &[
2396            (
2397                "index.js",
2398                "import 'node:fs';\nimport './broken.js';\nexport default 1;",
2399            ),
2400            ("broken.js", "export const = ;\n"),
2401        ],
2402    );
2403    server.publish_package(
2404        "badexport",
2405        "1.0.0",
2406        serde_json::json!({"exports": {".": "./index.js", "./bad": "./missing.js"}}),
2407        &[("index.js", "export default 1;")],
2408    );
2409    for (target, fatal) in [("npm:envbad", "nope.js"), ("npm:envsyntax", "broken.js")] {
2410        let dir = tempfile::tempdir().unwrap();
2411        fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2412        let output = server.run_npm(dir.path(), &["add", target], "");
2413        let stderr = failure(&output);
2414        assert!(
2415            stderr.contains("Warning") && stderr.contains("node:fs"),
2416            "{target}: {stderr:?}"
2417        );
2418        assert!(
2419            stderr.contains(fatal),
2420            "{target}: underlying failure must be reported, not just the warning: {stderr:?}"
2421        );
2422    }
2423    for target in ["npm:needsdep", "npm:badexport"] {
2424        let dir = tempfile::tempdir().unwrap();
2425        fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2426        let output = server.run_npm(dir.path(), &["add", target], "");
2427        assert!(!output.status.success(), "{target}");
2428    }
2429}
2430
2431#[test]
2432fn builtin_edges_stay_fatal_outside_add() {
2433    let server = Registry::new();
2434    server.publish_package(
2435        "envtool",
2436        "1.0.0",
2437        serde_json::json!({}),
2438        &[("index.js", "import fs from 'node:fs';\nexport default fs;")],
2439    );
2440    let dir = tempfile::tempdir().unwrap();
2441    fs::write(dir.path().join("avril.conf"), "deps: []").unwrap();
2442    let output = server.run_npm(dir.path(), &["add", "npm:envtool"], "");
2443    assert!(output.status.success());
2444    fs::write(
2445        dir.path().join("main.ts"),
2446        "import fs from 'envtool'; console.log(fs);",
2447    )
2448    .unwrap();
2449    for args in [
2450        &["run"] as &[&str],
2451        &["cache"],
2452        &["run", "--offline", "--frozen"],
2453    ] {
2454        let output = server.run_npm(dir.path(), args, "main.ts");
2455        let stderr = failure(&output);
2456        assert!(
2457            stderr.contains("builtin modules are not supported"),
2458            "{args:?}: {stderr:?}"
2459        );
2460        assert!(!stderr.contains("Warning"), "{args:?}: {stderr:?}");
2461    }
2462}