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