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