char/avril

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

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

Charlotte Somextract avril-task crate + add avril:task module7f4058a

main
25.0 KiB725 linesraw
1use avril_runtime::package_config::{PackageConfig, Source, TaskCommand};
2use avril_runtime::registry::{npm::parse_npm_request, parse_jsr_request};
3use url::Url;
4
5fn base() -> Url {
6    Url::from_file_path(std::env::temp_dir().join("avril-schema/avril.conf")).unwrap()
7}
8
9#[test]
10fn metadata_exports_and_maps_are_validated_without_filesystem_discovery() {
11    let config = PackageConfig::parse(
12        r#"
13        name: '@app/demo', version: '1.2.3-beta.1', description: 'A local package',
14        exports: {'.': './mod.ts', './vector': './src/vector.ts'},
15        imports: {
16            assert: 'jsr:@std/assert@^1', 'assert/': 'jsr:@std/assert@^1/', blocked: null,
17            '../shared/util.ts': './util.ts',
18        },
19        deps: [
20            '../math', {from: '../format', name: '@app/format'},
21            'jsr:@std/path@^1', {from: 'jsr:/@std/path@^0.2', name: 'path-old'},
22            'npm:left-pad@1', {from: 'npm:@scope/widget@^2', name: 'widget'},
23        ],
24        scopes: {'./tests/': {assert: './test-assert.ts'}, './tests/unit/': {assert: null}},
25    "#,
26        &base(),
27    )
28    .unwrap();
29    assert_eq!(config.name.as_deref(), Some("@app/demo"));
30    assert_eq!(config.version.as_deref(), Some("1.2.3-beta.1"));
31    assert_eq!(config.description.as_deref(), Some("A local package"));
32    assert!(
33        config.exports["./vector"]
34            .target
35            .path()
36            .ends_with("src/vector.ts")
37    );
38    let dependencies: Vec<_> = config
39        .dependencies
40        .iter()
41        .map(|dependency| (dependency.name.as_deref(), &dependency.source))
42        .collect();
43    assert_eq!(
44        dependencies,
45        [
46            (
47                None,
48                &Source::Local(base().join("../math").unwrap().to_file_path().unwrap())
49            ),
50            (
51                Some("@app/format"),
52                &Source::Local(base().join("../format").unwrap().to_file_path().unwrap())
53            ),
54            (
55                Some("@std/path"),
56                &Source::Jsr(parse_jsr_request("jsr:@std/path@^1").unwrap()),
57            ),
58            (
59                Some("path-old"),
60                &Source::Jsr(parse_jsr_request("jsr:/@std/path@^0.2").unwrap()),
61            ),
62            (
63                Some("left-pad"),
64                &Source::Npm(parse_npm_request("npm:left-pad@1").unwrap()),
65            ),
66            (
67                Some("widget"),
68                &Source::Npm(parse_npm_request("npm:@scope/widget@^2").unwrap()),
69            ),
70        ]
71    );
72    let referrer = base().join("./main.ts").unwrap();
73    for (specifier, target) in [
74        ("@std/path", "jsr:@std/path@^1"),
75        ("@std/path/posix", "jsr:@std/path@^1/posix"),
76        ("path-old/posix/join", "jsr:/@std/path@^0.2/posix/join"),
77        ("left-pad", "npm:left-pad@1"),
78        ("left-pad/sub", "npm:left-pad@1/sub"),
79        ("widget/sub", "npm:@scope/widget@^2/sub"),
80    ] {
81        assert_eq!(
82            config
83                .registry_map
84                .resolve(specifier, &referrer)
85                .unwrap()
86                .as_deref(),
87            Some(target)
88        );
89    }
90    for specifier in ["@std/path-extra", "path-old/../x", "@std/path/a%2fb"] {
91        assert!(
92            !matches!(
93                config.registry_map.resolve(specifier, &referrer),
94                Ok(Some(_))
95            ),
96            "{specifier}"
97        );
98    }
99    let referrer = base().join("./main.ts").unwrap();
100    assert_eq!(
101        config
102            .import_map
103            .resolve("assert", &referrer)
104            .unwrap()
105            .as_deref(),
106        Some("jsr:@std/assert@^1")
107    );
108    assert!(
109        config
110            .import_map
111            .resolve("blocked", &referrer)
112            .unwrap_err()
113            .to_string()
114            .contains("blocked")
115    );
116    assert_eq!(
117        config
118            .import_map
119            .resolve("assert/equals", &referrer)
120            .unwrap()
121            .as_deref(),
122        Some("jsr:@std/assert@^1/equals")
123    );
124    assert_eq!(
125        config
126            .import_map
127            .resolve("../shared/util.ts", &referrer)
128            .unwrap(),
129        Some(base().join("./util.ts").unwrap().to_string())
130    );
131    let referrer = base().join("./tests/main.ts").unwrap();
132    assert_eq!(
133        config.import_map.resolve("assert", &referrer).unwrap(),
134        Some(base().join("./test-assert.ts").unwrap().to_string())
135    );
136    let referrer = base().join("./tests/unit/main.ts").unwrap();
137    assert!(config.import_map.resolve("assert", &referrer).is_err());
138    assert_eq!(
139        config
140            .import_map
141            .resolve("assert/equals", &referrer)
142            .unwrap()
143            .as_deref(),
144        Some("jsr:@std/assert@^1/equals")
145    );
146    assert_eq!(
147        PackageConfig::parse("exports: './mod.ts'", &base())
148            .unwrap()
149            .exports
150            .len(),
151        1
152    );
153    assert!(
154        PackageConfig::parse("// empty", &base())
155            .unwrap()
156            .exports
157            .is_empty()
158    );
159}
160
161#[test]
162fn unknown_options_are_ignored_at_every_config_level() {
163    for extra in [
164        "null",
165        "true",
166        "42",
167        "'custom'",
168        "[null, true, 42, {nested: 'value'}]",
169        "{name: false, tasks: [], deps: {nested: 'value'}}",
170    ] {
171        let config = PackageConfig::parse(
172            &format!(
173                "custom: {extra}, importMap: {{}},
174                 name: 'app', imports: {{helper: './helper.ts'}},
175                 deps: [{{from: '../math', name: 'math', custom: {extra}, version: '1'}}],
176                 tasks: {{build: {{command: 'echo built', custom: {extra}, cwd: '.'}}}}"
177            ),
178            &base(),
179        )
180        .unwrap();
181        assert_eq!(config.name.as_deref(), Some("app"));
182        assert_eq!(config.dependencies.len(), 1);
183        assert_eq!(config.dependencies[0].name.as_deref(), Some("math"));
184        assert_eq!(
185            config.dependencies[0].source,
186            Source::Local(base().join("../math").unwrap().to_file_path().unwrap())
187        );
188        assert_eq!(
189            config.tasks["build"].command,
190            Some(TaskCommand::Shell("echo built".into()))
191        );
192        assert_eq!(
193            config.import_map.resolve("helper", &base()).unwrap(),
194            Some(base().join("./helper.ts").unwrap().to_string())
195        );
196    }
197}
198
199#[test]
200fn malformed_headers_and_paths_have_source_spans() {
201    for (source, message) in [
202        ("name: null", "Expected a string"),
203        ("name: '@scope'", "bare package name"),
204        ("name: 'a/b'", "bare package name"),
205        ("name: 'jsr:@scope/pkg'", "bare package name"),
206        ("version: 'next'", "Invalid package version"),
207        ("description: []", "Expected a string"),
208        ("exports: false", "Expected an object"),
209        ("exports: {'./*': './mod.ts'}", "exact"),
210        ("exports: {'./a/../b': './mod.ts'}", "exact"),
211        ("exports: './'", "file inside"),
212        ("exports: './sub/..'", "file inside"),
213        ("exports: '../private.ts'", "package-relative"),
214        ("exports: './sub/../../private.ts'", "file inside"),
215        ("exports: './%2e%2e/private.ts'", "file inside"),
216        (
217            "exports: './x%2f..%2f..%2fprivate.ts'",
218            "Invalid package path",
219        ),
220        ("exports: './x%5c..%5cprivate.ts'", "Invalid package path"),
221        ("exports: './mod.ts?raw'", "queries or fragments"),
222        ("exports: './mod.ts#fragment'", "queries or fragments"),
223        ("exports: './%00.ts'", "without NUL"),
224        ("exports: './%FF.ts'", "UTF-8"),
225        ("imports: []", "Expected an object"),
226        ("scopes: {'./tests/': []}", "Expected an object"),
227        ("deps: {math: '../math'}", "Expected an array"),
228        ("deps: [false]", "Expected a dependency path"),
229        ("deps: [['../math']]", "Expected a dependency path"),
230        ("deps: ['/math']", "config-relative"),
231        ("deps: ['math']", "config-relative"),
232        ("deps: ['./%2e%2e%5cmath']", "Invalid package path"),
233        ("deps: [{from: '../math'}]", "require both"),
234        ("deps: [{name: 'math'}]", "require both"),
235        (
236            "deps: [{from: '../math', name: false}]",
237            "Expected a string",
238        ),
239        ("deps: [{from: false, name: 'math'}]", "Expected a string"),
240        (
241            "deps: [{from: '../math', name: 'math/sub'}]",
242            "binding name",
243        ),
244        ("deps: [{from: '/math', name: 'math'}]", "config-relative"),
245        ("deps: ['jsr:@std/path@^1/posix']", "whole packages"),
246        ("deps: ['jsr:@std/path@^1/']", "whole packages"),
247        ("deps: ['jsr:@std/path/posix']", "whole packages"),
248        ("deps: ['jsr:@std/path@latest']", "version tags"),
249        ("deps: ['jsr:path@1']", "must be scoped"),
250        ("deps: ['jsr:@Test/path@1']", "Invalid JSR package name"),
251        (
252            "deps: [{from: 'jsr:@test/p.kg@1', name: 'pkg'}]",
253            "Invalid JSR package name",
254        ),
255        ("deps: ['jsr:']", "jsr:"),
256        ("deps: ['npm:left-pad/sub']", "whole packages"),
257        ("deps: [{path: '../math', name: 'math'}]", "require both"),
258        (
259            "deps: [{from: './%2e%2e%2fmath', name: 'math'}]",
260            "Invalid package path",
261        ),
262    ] {
263        let error = PackageConfig::parse(source, &base()).unwrap_err();
264        assert!(error.message.contains(message), "{source}: {error}");
265        assert!(!source[error.span.clone()].is_empty(), "{source}: {error}");
266        assert!(error.render("avril.conf", source).contains("avril.conf:1:"));
267    }
268}
269
270#[test]
271fn collisions_are_about_bindings_and_include_scoped_or_blocked_aliases() {
272    for source in [
273        "name: 'math', deps: [{from: '../math', name: 'math'}]",
274        "deps: [{from: '../math', name: 'math'}, {from: '../other', name: 'math'}]",
275        "name: 'math', imports: {math: './other.ts'}",
276        "name: 'math', imports: {'math/private': null}",
277        "deps: [{from: '../math', name: 'math'}], imports: {'math/': './other/'}",
278        "deps: [{from: '../math', name: '@acme/math'}], imports: {'@acme/': './other/'}",
279        "deps: [{from: '../math', name: 'math'}], scopes: {'./tests/': {math: './other.ts'}}",
280        "deps: ['jsr:@acme/math@1', {from: 'jsr:@acme/math@2', name: '@acme/math'}]",
281        "name: '@acme/math', deps: ['jsr:@acme/math@1']",
282        "deps: ['jsr:@acme/math@1'], imports: {'@acme/math/vector': './vector.ts'}",
283        "deps: ['jsr:@acme/math@1'], scopes: {'./tests/': {'@acme/': './other/'}}",
284    ] {
285        let error = PackageConfig::parse(source, &base()).unwrap_err();
286        assert!(
287            ["conflicts", "Duplicate"]
288                .iter()
289                .any(|message| error.message.contains(message)),
290            "{source}: {error}"
291        );
292        assert!(
293            source[error.span.clone()].contains("math"),
294            "{source}: {error}"
295        );
296    }
297    PackageConfig::parse("name: '@acme/math', deps: [{from: '../math', name: 'math'}, {from: '../other', name: 'math-next'}, {from: '../math', name: 'math-copy'}], imports: {'@acme/math-next': './other.ts'}", &base()).unwrap();
298}
299
300#[test]
301fn binding_diagnostics_point_at_the_binding_and_its_first_definition() {
302    for (source, at, previous) in [
303        (
304            "deps: [{from: '../a', name: 'math'}, {from: '../b', name: 'm\\u0061th'}]",
305            "'m\\u0061th'",
306            "'math'",
307        ),
308        (
309            "name: 'math', deps: [{from: '../math', name: \"math\"}]",
310            "\"math\"",
311            "'math'",
312        ),
313    ] {
314        let error = PackageConfig::parse(source, &base()).unwrap_err();
315        assert_eq!(&source[error.span.clone()], at, "{source}: {error}");
316        assert_eq!(
317            &source[error.previous.clone().unwrap()],
318            previous,
319            "{source}: {error}"
320        );
321    }
322}
323
324#[test]
325fn path_only_dependencies_bind_declared_names_with_the_same_validation() {
326    let names: &[(&str, Option<&str>)] = &[
327        ("math", Some("@acme/math")),
328        ("math-v2", Some("@acme/math")),
329        ("anonymous", None),
330    ];
331    let declared = |source: &Source| {
332        let Source::Local(directory) = source else {
333            panic!("{source:?}")
334        };
335        names
336            .iter()
337            .find(|(path, _)| directory.ends_with(path))
338            .map(|(_, name)| name.map(str::to_owned))
339            .ok_or_else(|| "unreadable".to_owned())
340    };
341    let source = "deps: ['../math', {from: '../math-v2', name: 'math-next'}, {from: '../math', name: 'math'}]";
342    let config = PackageConfig::parse(source, &base()).unwrap();
343    let bindings = futures::executor::block_on(
344        config.bindings(|source| std::future::ready(declared(&source))),
345    )
346    .unwrap();
347    let bindings: Vec<_> = bindings
348        .iter()
349        .map(|(name, source)| {
350            let Source::Local(path) = source else {
351                panic!("{source:?}")
352            };
353            (name.as_str(), path.file_name().unwrap().to_str().unwrap())
354        })
355        .collect();
356    assert_eq!(
357        bindings,
358        [
359            ("@acme/math", "math"),
360            ("math", "math"),
361            ("math-next", "math-v2")
362        ]
363    );
364    for (source, at, message, declarer) in [
365        (
366            "deps: ['../anonymous']",
367            "'../anonymous'",
368            "declares no name",
369            "anonymous",
370        ),
371        (
372            "deps: ['../unreadable']",
373            "'../unreadable'",
374            "unreadable",
375            "unreadable",
376        ),
377        (
378            "deps: ['../math', '../math-v2']",
379            "'../math-v2'",
380            "Duplicate",
381            "math-v2",
382        ),
383        (
384            "deps: [{from: '../other', name: '@acme/math'}, '../math']",
385            "'../math'",
386            "Duplicate",
387            "math",
388        ),
389        (
390            "deps: ['../math', {from: '../other', name: '@acme/math'}]",
391            "'@acme/math'",
392            "Duplicate",
393            "math",
394        ),
395        (
396            "deps: ['jsr:@acme/math@1', '../math']",
397            "'../math'",
398            "Duplicate",
399            "math",
400        ),
401        (
402            "name: '@acme/math', deps: ['../math']",
403            "'../math'",
404            "self name",
405            "math",
406        ),
407        (
408            "imports: {'@acme/': './vendor/'}, deps: ['../math']",
409            "'../math'",
410            "import key",
411            "math",
412        ),
413        (
414            "scopes: {'./tests/': {'@acme/math/vector': null}}, deps: ['../math']",
415            "'../math'",
416            "import key",
417            "math",
418        ),
419    ] {
420        let config = PackageConfig::parse(source, &base()).unwrap();
421        let error = futures::executor::block_on(
422            config.bindings(|source| std::future::ready(declared(&source))),
423        )
424        .unwrap_err();
425        assert_eq!(&source[error.span.clone()], at, "{source}: {error}");
426        assert!(error.message.contains(message), "{source}: {error}");
427        let declarer = base().join(&format!("../{declarer}/avril.conf")).unwrap();
428        assert!(
429            error
430                .message
431                .contains(declarer.to_file_path().unwrap().to_str().unwrap()),
432            "{source}: {error}"
433        );
434    }
435}
436
437#[test]
438fn tasks_accept_string_and_object_forms() {
439    let config = PackageConfig::parse(
440        r#"
441        tasks: {
442            build: 'avril run build.ts',
443            'build:client': {command: 'avril run client.ts', description: 'Build the client'},
444            serve: {command: 'avril run server.ts', dependencies: ['build', 'build:client'], envFile: '.env.serve'},
445            all: {dependencies: ['serve']},
446        },
447    "#,
448        &base(),
449    )
450    .unwrap();
451    assert_eq!(config.tasks.len(), 4);
452    assert_eq!(
453        config.tasks["build"].command,
454        Some(TaskCommand::Shell("avril run build.ts".into()))
455    );
456    assert_eq!(
457        config.tasks["build:client"].description.as_deref(),
458        Some("Build the client")
459    );
460    let dependencies: Vec<_> = config.tasks["serve"]
461        .dependencies
462        .iter()
463        .map(|dependency| dependency.name.as_str())
464        .collect();
465    assert_eq!(dependencies, ["build", "build:client"]);
466    assert_eq!(
467        config.tasks["serve"].env_file.as_ref().unwrap().path,
468        ".env.serve"
469    );
470    assert_eq!(config.tasks["all"].command, None);
471}
472
473#[test]
474fn parallel_tasks_run_their_dependencies_at_once() {
475    let config = PackageConfig::parse(
476        "tasks: {a: 'echo a', b: 'echo b', all: {dependencies: ['a', 'b'], parallel: true}, seq: {dependencies: ['a']}}",
477        &base(),
478    )
479    .unwrap();
480    assert!(config.tasks["all"].parallel);
481    assert!(!config.tasks["seq"].parallel);
482    assert!(!config.tasks["a"].parallel);
483    // parallel: false needs no dependencies; it is the default.
484    PackageConfig::parse("tasks: {a: {command: 'echo a', parallel: false}}", &base()).unwrap();
485    for (source, message) in [
486        (
487            "tasks: {all: {dependencies: ['a'], parallel: 'yes'}}",
488            "Expected a boolean",
489        ),
490        (
491            "tasks: {all: {command: 'echo a', parallel: true}}",
492            "requires 'dependencies'",
493        ),
494        (
495            "tasks: {all: {dependencies: [], parallel: true}}",
496            "requires 'dependencies'",
497        ),
498        (
499            "tasks: {a: {command: 'echo a', envFile: 3}}",
500            "Expected a string",
501        ),
502        (
503            "tasks: {a: {command: 'echo a', envFile: ''}}",
504            "Task env file paths cannot be empty",
505        ),
506        ("tasks: {a: true}", "'parallel'"),
507    ] {
508        let error = PackageConfig::parse(source, &base()).unwrap_err();
509        assert!(error.message.contains(message), "{source}: {error}");
510        assert!(!source[error.span.clone()].is_empty(), "{source}: {error}");
511    }
512}
513
514#[test]
515fn cached_tasks_declare_inputs_outputs_and_env_names() {
516    let config = PackageConfig::parse(
517        r#"
518        tasks: {
519            site: {
520                command: 'avril run build-site.ts',
521                files: ['src/**/*.ts', 'templates/', 'avril.conf'],
522                output: ['dist/'],
523                env: ['SITE_ENV', '_PRIVATE2'],
524            },
525        },
526    "#,
527        &base(),
528    )
529    .unwrap();
530    let site = &config.tasks["site"];
531    let files: Vec<_> = site
532        .files
533        .as_ref()
534        .unwrap()
535        .iter()
536        .map(|pattern| pattern.pattern.as_str())
537        .collect();
538    assert_eq!(files, ["src/**/*.ts", "templates/", "avril.conf"]);
539    assert_eq!(site.output[0].pattern, "dist/");
540    assert_eq!(site.env, ["SITE_ENV", "_PRIVATE2"]);
541    // Uncached tasks carry no caching fields.
542    let config = PackageConfig::parse("tasks: {a: 'echo hi'}", &base()).unwrap();
543    assert_eq!(config.tasks["a"].files, None);
544
545    for (source, message) in [
546        (
547            "tasks: {a: {command: 'x', output: ['dist/']}}",
548            "require 'files'",
549        ),
550        (
551            "tasks: {a: {command: 'x', env: ['HOME']}}",
552            "require 'files'",
553        ),
554        (
555            "tasks: {a: {command: 'x', files: 'src/**'}}",
556            "array of glob patterns",
557        ),
558        (
559            "tasks: {a: {command: 'x', files: [3]}}",
560            "Expected a string",
561        ),
562        ("tasks: {a: {command: 'x', files: ['']}}", "cannot be empty"),
563        (
564            "tasks: {a: {command: 'x', files: ['src\\\\**']}}",
565            "separator",
566        ),
567        (
568            "tasks: {a: {command: 'x', files: ['src/[abc']}}",
569            "invalid range pattern",
570        ),
571        (
572            "tasks: {a: {command: 'x', files: [], output: ['../dist']}}",
573            "inside the package",
574        ),
575        (
576            "tasks: {a: {command: 'x', files: [], output: ['/dist']}}",
577            "inside the package",
578        ),
579        (
580            "tasks: {a: {command: 'x', files: [], output: ['.']}}",
581            "inside the package",
582        ),
583        (
584            "tasks: {a: {command: 'x', files: [], env: 'HOME'}}",
585            "array of environment variable names",
586        ),
587        (
588            "tasks: {a: {command: 'x', files: [], env: ['1BAD']}}",
589            "environment variable name",
590        ),
591        (
592            "tasks: {a: {command: 'x', files: [], env: ['A-B']}}",
593            "environment variable name",
594        ),
595        (
596            "tasks: {a: {command: 'x', files: [], env: ['A', 'A']}}",
597            "Duplicate task env name",
598        ),
599    ] {
600        let error = PackageConfig::parse(source, &base()).unwrap_err();
601        assert!(error.message.contains(message), "{source}: {error}");
602        assert!(!source[error.span.clone()].is_empty(), "{source}: {error}");
603    }
604}
605
606#[test]
607fn malformed_tasks_have_source_spans() {
608    for (source, message) in [
609        ("tasks: []", "Expected an object"),
610        ("tasks: {'my task': 'echo hi'}", "Task names"),
611        ("tasks: {'': 'echo hi'}", "Task names"),
612        ("tasks: {build: ''}", "cannot be empty"),
613        ("tasks: {build: '  '}", "cannot be empty"),
614        ("tasks: {build: {}}", "must define"),
615        ("tasks: {build: {command: 3}}", "Expected a string"),
616        ("tasks: {build: {description: 'x'}}", "must define"),
617        (
618            "tasks: {build: {command: 3, cwd: '.'}}",
619            "Expected a string",
620        ),
621        (
622            "tasks: {build: {command: 'x', dependencies: 'build'}}",
623            "array of task names",
624        ),
625        (
626            "tasks: {build: {command: 'x', dependencies: [3]}}",
627            "Expected a string",
628        ),
629        (
630            "tasks: {build: {command: 'x', dependencies: ['a b']}}",
631            "Expected a task name",
632        ),
633        (
634            "tasks: {build: {command: 'x', dependencies: ['a', 'a']}}",
635            "Duplicate task dependency",
636        ),
637        ("tasks: {build: true}", "Expected a task command"),
638    ] {
639        let error = PackageConfig::parse(source, &base()).unwrap_err();
640        assert!(error.message.contains(message), "{source}: {error}");
641        assert!(!source[error.span.clone()].is_empty(), "{source}: {error}");
642    }
643    // Unknown and cyclic references are for the runner to judge: configs are
644    // parsed for many commands that never run tasks.
645    PackageConfig::parse(
646        "tasks: {a: {command: 'x', dependencies: ['b', 'missing']}, b: {command: 'y', dependencies: ['a']}}",
647        &base(),
648    )
649    .unwrap();
650}
651
652#[test]
653fn http_packages_use_url_paths_and_confined_exports() {
654    let base = Url::parse("https://example.com/pkg/avril.conf").unwrap();
655    let config = PackageConfig::parse("name: 'pkg', exports: './src/mod.ts', deps: ['../dep/', {from: 'http://other.example/lib/', name: 'other'}, 'jsr:@std/assert@1']", &base).unwrap();
656    assert_eq!(
657        config.exports["."].target.as_str(),
658        "https://example.com/pkg/src/mod.ts"
659    );
660    assert_eq!(
661        config.dependencies[0].source,
662        Source::Http(Url::parse("https://example.com/dep/").unwrap())
663    );
664    assert_eq!(config.dependencies[1].name.as_deref(), Some("other"));
665    let config = PackageConfig::parse("deps: ['https://example.com/pkg/']", &self::base()).unwrap();
666    assert_eq!(
667        config.dependencies[0].source,
668        Source::Http(base.join("./").unwrap())
669    );
670
671    for (source, message) in [
672        ("exports: './%2e%2e/private.ts'", "inside its package"),
673        ("exports: './sub/.'", "inside its package"),
674        ("exports: './x%2fprivate.ts'", "Invalid package path"),
675        ("exports: './mod.ts?raw'", "queries or fragments"),
676        ("exports: './%00.ts'", "without NUL"),
677        ("deps: ['../dep']", "ending with '/'"),
678        ("deps: ['https://user@example.com/pkg/']", "credential-free"),
679        (
680            "deps: ['https://example.com/pkg/?v=1']",
681            "queries or fragments",
682        ),
683        (
684            "deps: ['https://example.com/pkg/#v1']",
685            "queries or fragments",
686        ),
687        (
688            "deps: ['https://example.com/pkg/%2f/']",
689            "Invalid package path",
690        ),
691        ("deps: ['file:///tmp/pkg/']", "Dependencies must"),
692    ] {
693        let error = PackageConfig::parse(source, &base).unwrap_err();
694        assert!(error.message.contains(message), "{source}: {error}");
695    }
696}
697
698#[test]
699fn compiler_options_keep_all_keys_and_validate_jsx_ones() {
700    let config = PackageConfig::parse(
701        "compilerOptions: { jsx: 'react-jsxdev', jsxImportSource: 'ui', jsxFactory: 'h.x', target: 'es2020', paths: {} }",
702        &base(),
703    )
704    .unwrap();
705    assert_eq!(
706        serde_json::Value::Object(config.compiler_options),
707        serde_json::json!({"jsx": "react-jsxdev", "jsxImportSource": "ui", "jsxFactory": "h.x", "target": "es2020", "paths": {}})
708    );
709    for (source, message) in [
710        (
711            "compilerOptions: { jsx: 'preact' }",
712            "Unsupported compilerOptions.jsx",
713        ),
714        ("compilerOptions: { jsx: '' }", "nonempty string"),
715        ("compilerOptions: { jsxFactory: 42 }", "nonempty string"),
716        (
717            "compilerOptions: { jsxFactory: 'not a factory' }",
718            "dotted member expression",
719        ),
720        ("compilerOptions: []", "Expected an object"),
721    ] {
722        let error = PackageConfig::parse(source, &base()).unwrap_err();
723        assert!(error.message.contains(message), "{source}: {error}");
724    }
725}