turbopack_create_test_app/
test_app_builder.rs1use 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
45pub enum EffectMode {
46 #[default]
48 None,
49 Hook,
51 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 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 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 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 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 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 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 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 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 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#[derive(Debug)]
534pub struct PackageJsonConfig {
535 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 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 pub fn path(&self) -> &Path {
572 self.target.path()
573 }
574
575 pub fn modules(&self) -> &[(PathBuf, usize)] {
577 &self.modules
578 }
579}