Skip to main content

turbopack_create_test_app/
test_app_builder.rs

1use std::{
2    collections::VecDeque,
3    fs::{File, create_dir_all},
4    io::prelude::*,
5    path::{Path, PathBuf},
6    str::FromStr,
7};
8
9use anyhow::{Context, Result, bail};
10use indoc::{formatdoc, indoc};
11use serde_json::json;
12use tempfile::TempDir;
13
14fn decide(remaining: usize, min_remaining_decisions: usize) -> bool {
15    if remaining == 0 {
16        false
17    } else if min_remaining_decisions <= remaining {
18        true
19    } else {
20        let urgency = min_remaining_decisions / remaining;
21        (min_remaining_decisions * 11 * 7 * 5).is_multiple_of(urgency)
22    }
23}
24
25fn decide_early(remaining: usize, min_remaining_decisions: usize) -> bool {
26    if remaining == 0 {
27        false
28    } else if min_remaining_decisions <= remaining {
29        true
30    } else {
31        let urgency = min_remaining_decisions / remaining / remaining;
32        (min_remaining_decisions * 11 * 7 * 5).is_multiple_of(urgency)
33    }
34}
35
36fn write_file<P: AsRef<Path>>(name: &str, path: P, content: &[u8]) -> Result<()> {
37    File::create(path)
38        .with_context(|| format!("creating {name}"))?
39        .write_all(content)
40        .with_context(|| format!("writing {name}"))
41}
42
43/// How to run effects in components.
44#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
45pub enum EffectMode {
46    /// No effects at all.
47    #[default]
48    None,
49    /// As a direct `useEffect` hook in the component's body.
50    Hook,
51    /// Rendering an `<Effect />` client-side component that has the `useEffect` hook instead. Good
52    /// for testing React Server Components, as they can't use `useEffect` hooks directly.
53    Component,
54}
55
56impl FromStr for EffectMode {
57    type Err = anyhow::Error;
58
59    fn from_str(s: &str) -> Result<Self, Self::Err> {
60        match s {
61            "none" => Ok(EffectMode::None),
62            "hook" => Ok(EffectMode::Hook),
63            "component" => Ok(EffectMode::Component),
64            _ => bail!("unknown effect mode: {}", s),
65        }
66    }
67}
68
69#[derive(Debug)]
70pub struct TestAppBuilder {
71    pub target: Option<PathBuf>,
72    pub module_count: usize,
73    pub directories_count: usize,
74    pub dynamic_import_count: usize,
75    pub flatness: usize,
76    pub package_json: Option<PackageJsonConfig>,
77    pub effect_mode: EffectMode,
78    pub leaf_client_components: bool,
79}
80
81impl Default for TestAppBuilder {
82    fn default() -> Self {
83        Self {
84            target: None,
85            module_count: 1000,
86            directories_count: 50,
87            dynamic_import_count: 0,
88            flatness: 5,
89            package_json: Some(Default::default()),
90            effect_mode: EffectMode::Hook,
91            leaf_client_components: false,
92        }
93    }
94}
95
96const SETUP_IMPORTS: &str = indoc! {r#"
97import React from "react";
98"#};
99const SETUP_EFFECT_PROPS: &str = indoc! {r#"
100let EFFECT_PROPS = {};
101"#};
102const SETUP_EVAL: &str = indoc! {r#"
103/* @turbopack-bench:eval-start */
104/* @turbopack-bench:eval-end */
105"#};
106const USE_EFFECT: &str = indoc! {r#"
107React.useEffect(() => {
108    if (EFFECT_PROPS.hydration) {
109        globalThis.__turbopackBenchBinding && globalThis.__turbopackBenchBinding("Hydration done");
110    }
111    if (EFFECT_PROPS.message) {
112        globalThis.__turbopackBenchBinding && globalThis.__turbopackBenchBinding(EFFECT_PROPS.message);
113    }
114}, [EFFECT_PROPS]);
115"#};
116const EFFECT_ELEMENT: &str = indoc! {r#"
117<Effect {...EFFECT_PROPS} />
118"#};
119
120impl TestAppBuilder {
121    pub fn build(&self) -> Result<TestApp> {
122        let target = if let Some(target) = self.target.clone() {
123            TestAppTarget::Set(target)
124        } else {
125            TestAppTarget::Temp(tempfile::tempdir().context("creating tempdir")?)
126        };
127        let path = target.path();
128        let mut modules = vec![];
129        let src = path.join("src");
130        create_dir_all(&src).context("creating src dir")?;
131
132        let mut remaining_modules = self.module_count - 1;
133        let mut remaining_directories = self.directories_count;
134        let mut remaining_dynamic_imports = self.dynamic_import_count;
135
136        let mut queue = VecDeque::with_capacity(32);
137        queue.push_back((src.join("triangle.jsx"), 0));
138        remaining_modules -= 1;
139        let mut is_root = true;
140
141        let (additional_body, additional_elements) = match self.effect_mode {
142            EffectMode::None => ("", ""),
143            EffectMode::Component => ("", EFFECT_ELEMENT),
144            EffectMode::Hook => (USE_EFFECT, ""),
145        };
146
147        while let Some((file, depth)) = queue.pop_front() {
148            modules.push((file.clone(), depth));
149
150            let setup_imports = match self.effect_mode {
151                EffectMode::Hook | EffectMode::None => SETUP_IMPORTS.to_string(),
152                EffectMode::Component => {
153                    let relative_effect = if src == file.parent().unwrap() {
154                        "./effect.jsx".to_string()
155                    } else {
156                        pathdiff::diff_paths(src.join("effect.jsx"), file.parent().unwrap())
157                            .unwrap()
158                            .display()
159                            .to_string()
160                    };
161
162                    #[cfg(windows)]
163                    let relative_effect = relative_effect.replace('\\', "/");
164
165                    formatdoc! {r#"
166                        {SETUP_IMPORTS}
167                        import Effect from "{relative_effect}";
168                    "#}
169                }
170            };
171
172            let leaf = remaining_modules == 0
173                || (!queue.is_empty()
174                    && (queue.len() + remaining_modules).is_multiple_of(self.flatness + 1));
175            if leaf {
176                let maybe_use_client = if self.leaf_client_components {
177                    r#""use client";"#
178                } else {
179                    ""
180                };
181                write_file(
182                    &format!("leaf file {}", file.display()),
183                    &file,
184                    formatdoc! {r#"
185                        {maybe_use_client}
186
187                        {setup_imports}
188
189                        {SETUP_EFFECT_PROPS}
190                        {SETUP_EVAL}
191
192                        function Triangle({{ style }}) {{
193                            {additional_body}
194                            return <>
195                                <polygon points="-5,4.33 0,-4.33 5,4.33" style={{style}} />
196                                {additional_elements}
197                            </>;
198                        }}
199
200                        export default React.memo(Triangle);
201                    "#}
202                    .as_bytes(),
203                )?;
204            } else {
205                let in_subdirectory = decide(remaining_directories, remaining_modules / 3);
206
207                let import_path;
208                let base_file = file.with_extension("");
209                let base_file = if in_subdirectory {
210                    remaining_directories -= 1;
211                    create_dir_all(&base_file).context("creating subdirectory")?;
212                    import_path = format!(
213                        "./{}/triangle_",
214                        base_file.file_name().unwrap().to_str().unwrap()
215                    );
216                    base_file.join("triangle")
217                } else {
218                    import_path =
219                        format!("./{}_", base_file.file_name().unwrap().to_str().unwrap());
220                    base_file
221                };
222
223                for i in 1..=3 {
224                    let mut f = base_file.clone();
225                    f.set_file_name(format!(
226                        "{}_{}.jsx",
227                        f.file_name().unwrap().to_str().unwrap(),
228                        i
229                    ));
230                    queue.push_back((f, depth + 1));
231                }
232                remaining_modules = remaining_modules.saturating_sub(3);
233
234                if let [(a, a_), (b, b_), (c, c_)] = &*[("A", "1"), ("B", "2"), ("C", "3")]
235                    .into_iter()
236                    .enumerate()
237                    .map(|(i, (name, n))| {
238                        if decide_early(remaining_dynamic_imports, remaining_modules + (2 - i)) {
239                            remaining_dynamic_imports -= 1;
240                            (
241                                format!(
242                                    "const {name}Lazy = React.lazy(() => \
243                                     import('{import_path}{n}'));"
244                                ),
245                                format!(
246                                    "<React.Suspense><{name}Lazy style={{style}} \
247                                     /></React.Suspense>"
248                                ),
249                            )
250                        } else {
251                            (
252                                format!("import {name} from '{import_path}{n}'"),
253                                format!("<{name} style={{style}} />"),
254                            )
255                        }
256                    })
257                    .collect::<Vec<_>>()
258                {
259                    let setup_hydration = if is_root {
260                        is_root = false;
261                        "\nEFFECT_PROPS.hydration = true;"
262                    } else {
263                        ""
264                    };
265                    write_file(
266                        &format!("file with children {}", file.display()),
267                        &file,
268                        formatdoc! {r#"
269                            {setup_imports}
270                            {a}
271                            {b}
272                            {c}
273
274                            {SETUP_EFFECT_PROPS}{setup_hydration}
275                            {SETUP_EVAL}
276
277                            function Container({{ style }}) {{
278                                {additional_body}
279                                return <>
280                                    <g transform="translate(0 -2.16)   scale(0.5 0.5)">
281                                        {a_}
282                                    </g>
283                                    <g transform="translate(-2.5 2.16) scale(0.5 0.5)">
284                                        {b_}
285                                    </g>
286                                    <g transform="translate(2.5 2.16)  scale(0.5 0.5)">
287                                        {c_}
288                                    </g>
289                                    {additional_elements}
290                                </>;
291                            }}
292
293                            export default React.memo(Container);
294                        "#}
295                        .as_bytes(),
296                    )?;
297                } else {
298                    unreachable!()
299                }
300            }
301        }
302
303        let bootstrap = indoc! {r#"
304            import React from "react";
305            import { createRoot } from "react-dom/client";
306            import Triangle from "./triangle.jsx";
307
308            function App() {
309                return <svg height="100%" viewBox="-5 -4.33 10 8.66" style={{ }}>
310                    <Triangle style={{ fill: "white" }}/>
311                </svg>
312            }
313
314            document.body.style.backgroundColor = "black";
315            let root = document.createElement("main");
316            document.body.appendChild(root);
317            createRoot(root).render(<App />);
318        "#};
319        write_file(
320            "bootstrap file",
321            src.join("index.jsx"),
322            bootstrap.as_bytes(),
323        )?;
324
325        let pages = src.join("pages");
326        create_dir_all(&pages)?;
327
328        // The page is e. g. used by Next.js
329        let bootstrap_page = indoc! {r#"
330            import React from "react";
331            import Triangle from "../triangle.jsx";
332
333            export default function Page() {
334                return <svg height="100%" viewBox="-5 -4.33 10 8.66" style={{ backgroundColor: "black" }}>
335                    <Triangle style={{ fill: "white" }}/>
336                </svg>
337            }
338        "#};
339        write_file(
340            "bootstrap page",
341            pages.join("page.jsx"),
342            bootstrap_page.as_bytes(),
343        )?;
344
345        // The page is e. g. used by Next.js
346        let bootstrap_static_page = indoc! {r#"
347            import React from "react";
348            import Triangle from "../triangle.jsx";
349
350            export default function Page() {
351                return <svg height="100%" viewBox="-5 -4.33 10 8.66" style={{ backgroundColor: "black" }}>
352                    <Triangle style={{ fill: "white" }}/>
353                </svg>
354            }
355
356            export function getStaticProps() {
357                return {
358                    props: {}
359                };
360            }
361        "#};
362        write_file(
363            "bootstrap static page",
364            pages.join("static.jsx"),
365            bootstrap_static_page.as_bytes(),
366        )?;
367
368        let app_dir = src.join("app");
369        create_dir_all(app_dir.join("app"))?;
370        create_dir_all(app_dir.join("client"))?;
371
372        // The page is e. g. used by Next.js
373        let bootstrap_app_page = indoc! {r#"
374            import React from "react";
375            import Triangle from "../../triangle.jsx";
376
377            export default function Page() {
378                return <svg height="100%" viewBox="-5 -4.33 10 8.66" style={{ backgroundColor: "black" }}>
379                    <Triangle style={{ fill: "white" }}/>
380                </svg>
381            }
382        "#};
383        write_file(
384            "bootstrap app page",
385            app_dir.join("app/page.jsx"),
386            bootstrap_app_page.as_bytes(),
387        )?;
388
389        if matches!(self.effect_mode, EffectMode::Component) {
390            // The component is used to measure hydration and commit time for app/page.jsx
391            let effect_component = formatdoc! {r#"
392                "use client";
393
394                import React from "react";
395
396                export default function Effect(EFFECT_PROPS) {{
397                    {USE_EFFECT}
398                    return null;
399                }}
400            "#};
401            write_file(
402                "effect component",
403                src.join("effect.jsx"),
404                effect_component.as_bytes(),
405            )?;
406        }
407
408        // The page is e. g. used by Next.js
409        let bootstrap_app_client_page = indoc! {r#"
410            "use client";
411            import React from "react";
412            import Triangle from "../../triangle.jsx";
413
414            export default function Page() {
415                return <svg height="100%" viewBox="-5 -4.33 10 8.66" style={{ backgroundColor: "black" }}>
416                    <Triangle style={{ fill: "white" }}/>
417                </svg>
418            }
419        "#};
420        write_file(
421            "bootstrap app client page",
422            app_dir.join("client/page.jsx"),
423            bootstrap_app_client_page.as_bytes(),
424        )?;
425
426        // This root layout is e. g. used by Next.js
427        let bootstrap_layout = indoc! {r#"
428            export default function RootLayout({ children }) {
429                return (
430                    <html lang="en">
431                        <head>
432                            <meta charSet="UTF-8" />
433                            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
434                            <title>Turbopack Test App</title>
435                        </head>
436                        <body>
437                            {children}
438                        </body>
439                    </html>
440                );
441            }
442        "#};
443        write_file(
444            "bootstrap layout",
445            app_dir.join("layout.jsx"),
446            bootstrap_layout.as_bytes(),
447        )?;
448
449        // This HTML is used e. g. by Vite
450        let bootstrap_html = indoc! {r#"
451            <!DOCTYPE html>
452            <html lang="en">
453                <head>
454                    <meta charset="UTF-8" />
455                    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
456                    <title>Turbopack Test App</title>
457                </head>
458                <body>
459                    <script type="module" src="/src/index.jsx"></script>
460                </body>
461            </html>
462        "#};
463        write_file(
464            "bootstrap html in root",
465            path.join("index.html"),
466            bootstrap_html.as_bytes(),
467        )?;
468
469        // This HTML is used e. g. by webpack
470        let bootstrap_html2 = indoc! {r#"
471            <!DOCTYPE html>
472            <html lang="en">
473                <head>
474                    <meta charset="UTF-8" />
475                    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
476                    <title>Turbopack Test App</title>
477                </head>
478                <body>
479                    <script src="main.js"></script>
480                </body>
481            </html>
482        "#};
483
484        let public = path.join("public");
485        create_dir_all(&public).context("creating public dir")?;
486
487        write_file(
488            "bootstrap html",
489            public.join("index.html"),
490            bootstrap_html2.as_bytes(),
491        )?;
492
493        write_file(
494            "vite node.js server",
495            path.join("vite-server.mjs"),
496            include_bytes!("templates/vite-server.mjs"),
497        )?;
498        write_file(
499            "vite server entry",
500            path.join("src/vite-entry-server.jsx"),
501            include_bytes!("templates/vite-entry-server.jsx"),
502        )?;
503        write_file(
504            "vite client entry",
505            path.join("src/vite-entry-client.jsx"),
506            include_bytes!("templates/vite-entry-client.jsx"),
507        )?;
508
509        if let Some(package_json) = &self.package_json {
510            // These dependencies are needed
511            let package_json = json!({
512                "name": "turbopack-test-app",
513                "private": true,
514                "version": "0.0.0",
515                "dependencies": {
516                    "react": package_json.react_version.clone(),
517                    "react-dom": package_json.react_version.clone(),
518                }
519            });
520            write_file(
521                "package.json",
522                path.join("package.json"),
523                format!("{package_json:#}").as_bytes(),
524            )?;
525        }
526
527        Ok(TestApp { target, modules })
528    }
529}
530
531/// Configuration struct to generate the `package.json` file of the test app.
532#[derive(Debug)]
533pub struct PackageJsonConfig {
534    /// The version of React to use.
535    pub react_version: String,
536}
537
538impl Default for PackageJsonConfig {
539    fn default() -> Self {
540        Self {
541            react_version: "^18.2.0".to_string(),
542        }
543    }
544}
545
546#[derive(Debug)]
547enum TestAppTarget {
548    Set(PathBuf),
549    Temp(TempDir),
550}
551
552impl TestAppTarget {
553    /// Returns the path to the directory containing the app.
554    fn path(&self) -> &Path {
555        match &self {
556            TestAppTarget::Set(target) => target.as_path(),
557            TestAppTarget::Temp(target) => target.path(),
558        }
559    }
560}
561
562#[derive(Debug)]
563pub struct TestApp {
564    target: TestAppTarget,
565    modules: Vec<(PathBuf, usize)>,
566}
567
568impl TestApp {
569    /// Returns the path to the directory containing the app.
570    pub fn path(&self) -> &Path {
571        self.target.path()
572    }
573
574    /// Returns the list of modules and their depth in this app.
575    pub fn modules(&self) -> &[(PathBuf, usize)] {
576        &self.modules
577    }
578}