char/avril

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

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

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

main
7.4 KiB223 linesraw
1use std::fs;
2use std::path::Path;
3use std::process::{Command, Output};
4
5fn run(dir: &Path, files: &[(&str, &str)]) -> Output {
6    for (name, source) in files {
7        fs::write(dir.join(name), source).unwrap();
8    }
9    Command::new(env!("CARGO_BIN_EXE_avril"))
10        .arg("run")
11        .current_dir(dir)
12        .arg(files[0].0)
13        .output()
14        .unwrap()
15}
16
17fn success(output: Output) -> String {
18    assert!(
19        output.status.success(),
20        "{}",
21        String::from_utf8_lossy(&output.stderr)
22    );
23    String::from_utf8(output.stdout).unwrap()
24}
25
26#[test]
27fn loads_a_mixed_typescript_and_javascript_graph() {
28    let dir = tempfile::tempdir().unwrap();
29    let output = run(
30        dir.path(),
31        &[
32            (
33                "main.ts",
34                r#"
35                import type { Missing } from "./missing.ts";
36                import { Shape } from "./also-missing.ts";
37                import { type Count, count, message } from "./dep.mts";
38                import { count as again } from "././dep.mts";
39                import { describe } from "./bridge.js";
40                const shape: Shape | Missing | undefined = undefined;
41                const total: Count = await Promise.resolve(count + again);
42                console.log(message, total, describe(), shape);
43                "#,
44            ),
45            (
46                "dep.mts",
47                r#"
48                import { getMessage } from "./cycle.ts";
49                export type Count = number;
50                export const message: string = "hello";
51                export const count = (globalThis.count = (globalThis.count ?? 0) + 1) as Count;
52                export const cycled = () => getMessage();
53                "#,
54            ),
55            (
56                "cycle.ts",
57                r#"
58                import { message } from "./dep.mts";
59                export function getMessage(): string { return message; }
60                "#,
61            ),
62            (
63                "bridge.js",
64                r#"
65                import { cycled } from "./dep.mts";
66                export function describe() { return `${cycled()} from js`; }
67                "#,
68            ),
69        ],
70    );
71    assert_eq!(success(output), "hello 2 hello from js undefined\n");
72}
73
74#[test]
75fn runs_typescript_runtime_constructs() {
76    let dir = tempfile::tempdir().unwrap();
77    let output = run(
78        dir.path(),
79        &[(
80            "main.ts",
81            r#"
82            enum Color { Red, Green = "green", Blue = Red + 2 }
83            const enum Size { Small = 1, Large = Small * 10 }
84            namespace Geometry {
85                export const origin = { x: 0, y: 0 };
86                export namespace Nested { export const depth = 2; }
87            }
88            abstract class Base<T> {
89                declare readonly kind: string;
90                abstract value(): T;
91            }
92            class Point extends Base<number> {
93                constructor(public x: number, private readonly y: number = 2) { super(); }
94                value(): number { return this.x + this.y!; }
95            }
96            const config = { size: Size.Large } satisfies Record<string, number>;
97            const unchecked: number = "no type checking";
98            console.log(
99                Color.Red, Color[0], Color.Green, Color.Blue,
100                config.size, Geometry.origin.x, Geometry.Nested.depth,
101                new Point(1).value(), <number>(3 as unknown), unchecked,
102            );
103            "#,
104        )],
105    );
106    assert_eq!(
107        success(output),
108        "0 Red green 2 10 0 2 3 3 no type checking\n"
109    );
110}
111
112#[test]
113fn rejects_unsupported_and_malformed_typescript() {
114    for (name, source, expected) in [
115        ("main.ts", "const value: number = ;", "main.ts:1:23"),
116        ("main.ts", "let a = 1;\nlet a = 2;", "main.ts:1:5"),
117        ("main.ts", "export = 1;", "Export assignment cannot be used"),
118        ("main.ts", "import './types.d.ts';", "declaration files"),
119        ("main.d.ts", "export {};", "declaration files"),
120        ("main.d.mts", "export {};", "declaration files"),
121        // Explicit `.cts` now transpiles preserving CJS, so ESM-only syntax
122        // fails as a guest syntax error rather than "not supported".
123        ("main.cts", "export {};", "SyntaxError"),
124    ] {
125        let dir = tempfile::tempdir().unwrap();
126        let output = run(dir.path(), &[(name, source), ("types.d.ts", "export {};")]);
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        assert!(!output.status.success(), "{source}");
129        assert!(stderr.contains(expected), "{stderr}");
130    }
131}
132
133#[test]
134fn stack_traces_point_at_typescript_sources() {
135    let dir = tempfile::tempdir().unwrap();
136    let output = run(
137        dir.path(),
138        &[(
139            "main.ts",
140            r#"type Unused = {
141  field: string;
142};
143function fail(message: string): never {
144  throw new Error(message);
145}
146console.log(new Error("stack").stack);
147console.trace("traced");
148fail("uncaught" as string);
149"#,
150        )],
151    );
152    let path = dir.path().canonicalize().unwrap().join("main.ts");
153    let path = path.display();
154    let stdout = String::from_utf8_lossy(&output.stdout);
155    let stderr = String::from_utf8_lossy(&output.stderr);
156    assert!(stdout.contains(&format!("at {path}:7:13\n")), "{stdout}");
157    assert!(stderr.contains(&format!("at {path}:8:9\n")), "{stderr}");
158    assert!(
159        stderr.contains(&format!("at fail ({path}:5:9)\n")),
160        "{stderr}"
161    );
162    assert!(stderr.contains(&format!("at {path}:9:1")), "{stderr}");
163}
164
165#[test]
166fn source_mapping_handles_dependency_paths_and_utf16_columns() {
167    let dir = tempfile::tempdir().unwrap();
168    let line = "  const emoji: string = '😀'; throw new Error(emoji);";
169    let source = format!(
170        "type Dropped = {{\n  name: string;\n}};\nexport function fail(): never {{\n{line}\n}}\n"
171    );
172    let output = run(
173        dir.path(),
174        &[
175            (
176                "main.mts",
177                "import { fail } from './dep (mapped).ts'; await Promise.resolve(); fail();",
178            ),
179            ("dep (mapped).ts", &source),
180        ],
181    );
182    assert!(!output.status.success());
183    let column = line[..line.find("new Error").unwrap()]
184        .encode_utf16()
185        .count()
186        + 1;
187    let path = dir.path().canonicalize().unwrap().join("dep (mapped).ts");
188    let stderr = String::from_utf8_lossy(&output.stderr);
189    assert!(
190        stderr.contains(&format!("at fail ({}:5:{column})", path.display())),
191        "{stderr}"
192    );
193}
194
195#[test]
196fn mapped_stacks_capture_intrinsics_but_allow_custom_formatters() {
197    let dir = tempfile::tempdir().unwrap();
198    let output = run(
199        dir.path(),
200        &[(
201            "main.ts",
202            r#"interface Erased { value: number; }
203const original = Error.prepareStackTrace;
204const poison = () => { throw new Error("Poisoned intrinsic"); };
205Error.prototype.toString = poison;
206String.prototype.lastIndexOf = poison;
207String.prototype.slice = poison;
208Error.prepareStackTrace = original;
209console.log(new Error("mapped").stack);
210Error.prepareStackTrace = () => "custom";
211console.log(new Error().stack);
212"#,
213        )],
214    );
215    let stdout = success(output);
216    let path = dir.path().canonicalize().unwrap().join("main.ts");
217    assert!(stdout.starts_with("Error: mapped\n"), "{stdout}");
218    assert!(
219        stdout.contains(&format!("at {}:8:13", path.display())),
220        "{stdout}"
221    );
222    assert!(stdout.ends_with("custom\n"), "{stdout}");
223}