From b517426ebf5bc13f36780188abb3a81a0e0a8077 Mon Sep 17 00:00:00 2001 From: Alex Kirszenberg Date: Thu, 4 May 2023 15:02:34 +0200 Subject: [PATCH 1/9] Add test for HMR --- .../next-swc/crates/next-dev-tests/.gitignore | 1 + .../next-dev-tests/test-harness/harness.ts | 46 ++++- .../next-dev-tests/tests/integration.rs | 163 +++++++++++++++--- .../next/hmr/simple/input/pages/index.tsx | 56 ++++++ .../next/hmr/simple/input/pages/page.tsx | 11 ++ 5 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 packages/next-swc/crates/next-dev-tests/.gitignore create mode 100644 packages/next-swc/crates/next-dev-tests/tests/integration/next/hmr/simple/input/pages/index.tsx create mode 100644 packages/next-swc/crates/next-dev-tests/tests/integration/next/hmr/simple/input/pages/page.tsx diff --git a/packages/next-swc/crates/next-dev-tests/.gitignore b/packages/next-swc/crates/next-dev-tests/.gitignore new file mode 100644 index 000000000000..559953e86a97 --- /dev/null +++ b/packages/next-swc/crates/next-dev-tests/.gitignore @@ -0,0 +1 @@ +tests/temp \ No newline at end of file diff --git a/packages/next-swc/crates/next-dev-tests/test-harness/harness.ts b/packages/next-swc/crates/next-dev-tests/test-harness/harness.ts index 0d3bd2ef2ce1..2e624ef0bb0a 100644 --- a/packages/next-swc/crates/next-dev-tests/test-harness/harness.ts +++ b/packages/next-swc/crates/next-dev-tests/test-harness/harness.ts @@ -7,8 +7,10 @@ declare global { // We need to extract only the call signature as `autoReady(jest.describe)` drops all the other properties var describe: AutoReady var it: AutoReady - var READY: (arg: string) => void + var TURBOPACK_READY: (arg: string) => void + var TURBOPACK_CHANGE_FILE: (arg: string) => void var nsObj: (obj: any) => any + var __turbopackFileChanged: (id: string, error: Error) => void interface Window { NEXT_HYDRATED?: boolean @@ -62,8 +64,8 @@ function markReady() { isReady = true requestIdleCallback( () => { - if (typeof READY === 'function') { - READY('') + if (typeof TURBOPACK_READY === 'function') { + TURBOPACK_READY('') } else { console.info( '%cTurbopack tests:', @@ -210,3 +212,41 @@ export function markAsHydrated() { window.onNextHydrated() } } + +const fileChangedResolvers: Map< + string, + { resolve: (value: unknown) => void; reject: (error: Error) => void } +> = new Map() + +globalThis.__turbopackFileChanged = (id: string, error?: Error) => { + const resolver = fileChangedResolvers.get(id) + if (resolver == null) { + throw new Error(`No resolver found for id ${id}`) + } else if (error != null) { + resolver.reject(error) + } else { + resolver.resolve(null) + } +} + +function unsafeUniqueId(): string { + const LENGTH = 10 + const BASE = 16 + return Math.floor(Math.random() * Math.pow(BASE, LENGTH)) + .toString(BASE) + .slice(0, LENGTH) +} + +export async function changeFile( + path: string, + find: string, + replaceWith: string +) { + return new Promise((resolve, reject) => { + const id = unsafeUniqueId() + + fileChangedResolvers.set(id, { resolve, reject }) + + TURBOPACK_CHANGE_FILE(JSON.stringify({ path, id, find, replaceWith })) + }) +} diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration.rs b/packages/next-swc/crates/next-dev-tests/tests/integration.rs index cf344330de9e..cbe5a5dfbb97 100644 --- a/packages/next-swc/crates/next-dev-tests/tests/integration.rs +++ b/packages/next-swc/crates/next-dev-tests/tests/integration.rs @@ -22,6 +22,7 @@ use chromiumoxide::{ }, }, error::CdpError::Ws, + Page, }; use dunce::canonicalize; use futures::StreamExt; @@ -172,6 +173,27 @@ fn test_skipped_fails(resource: PathBuf) { ); } +fn copy_recursive(from: &Path, to: &Path) -> std::io::Result<()> { + let from = canonicalize(from)?; + let to = canonicalize(to)?; + let mut entries = vec![]; + for entry in from.read_dir()? { + let entry = entry?; + let path = entry.path(); + let to_path = to.join(path.file_name().unwrap()); + if path.is_dir() { + std::fs::create_dir_all(&to_path)?; + entries.push((path, to_path)); + } else { + std::fs::copy(&path, &to_path)?; + } + } + for (from, to) in entries { + copy_recursive(&from, &to)?; + } + Ok(()) +} + async fn run_test(resource: PathBuf) -> JestRunResult { register(); @@ -184,6 +206,21 @@ async fn run_test(resource: PathBuf) -> JestRunResult { ); let package_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let tests_dir = package_root.join("tests"); + let integration_tests_dir = tests_dir.join("integration"); + let resource_temp: PathBuf = tests_dir.join("temp").join( + resource + .strip_prefix(integration_tests_dir) + .expect("resource path must be within the integration tests directory"), + ); + + // We don't care about errors when removing the previous temp directory. + // It can still exist if we crashed during a previous test run. + let _ = std::fs::remove_dir_all(&resource_temp); + std::fs::create_dir_all(&resource_temp).expect("failed to create temporary directory"); + copy_recursive(&resource, &resource_temp) + .expect("failed to copy test files to temporary directory"); + let cargo_workspace_root = canonicalize(package_root) .unwrap() .parent() @@ -192,12 +229,12 @@ async fn run_test(resource: PathBuf) -> JestRunResult { .unwrap() .to_path_buf(); - let test_dir = resource.to_path_buf(); + let test_dir = resource_temp.to_path_buf(); let workspace_root = cargo_workspace_root.parent().unwrap().parent().unwrap(); let project_dir = test_dir.join("input"); let requested_addr = get_free_local_addr().unwrap(); - let mock_dir = resource.join("__httpmock__"); + let mock_dir = resource_temp.join("__httpmock__"); let mock_server_future = get_mock_server_future(&mock_dir); let (issue_tx, mut issue_rx) = unbounded_channel(); @@ -248,7 +285,7 @@ async fn run_test(resource: PathBuf) -> JestRunResult { let result = tokio::select! { // Poll the mock_server first to add the env var _ = mock_server_future => panic!("Never resolves"), - r = run_browser(local_addr) => r.expect("error while running browser"), + r = run_browser(local_addr, &project_dir) => r.expect("error while running browser"), _ = server.future => panic!("Never resolves"), }; @@ -257,7 +294,7 @@ async fn run_test(resource: PathBuf) -> JestRunResult { let task = tt.spawn_once_task(async move { let issues_fs = DiskFileSystemVc::new( "issues".to_string(), - test_dir.join("issues").to_string_lossy().to_string(), + resource.join("issues").to_string_lossy().to_string(), ) .as_file_system(); @@ -277,6 +314,8 @@ async fn run_test(resource: PathBuf) -> JestRunResult { }); tt.wait_task_completion(task, true).await.unwrap(); + std::fs::remove_dir_all(&resource_temp).expect("failed to remove temporary test directory"); + result } @@ -318,7 +357,16 @@ async fn create_browser(is_debugging: bool) -> Result<(Browser, JoinSet<()>)> { Ok((browser, set)) } -async fn run_browser(addr: SocketAddr) -> Result { +const TURBOPACK_READY_BINDING: &str = "TURBOPACK_READY"; +const TURBOPACK_DONE_BINDING: &str = "TURBOPACK_DONE"; +const TURBOPACK_CHANGE_FILE_BINDING: &str = "TURBOPACK_CHANGE_FILE"; +const BINDINGS: [&str; 3] = [ + TURBOPACK_READY_BINDING, + TURBOPACK_DONE_BINDING, + TURBOPACK_CHANGE_FILE_BINDING, +]; + +async fn run_browser(addr: SocketAddr, project_dir: &Path) -> Result { let is_debugging = *DEBUG_BROWSER; let (browser, mut handle) = create_browser(is_debugging).await?; @@ -334,7 +382,9 @@ async fn run_browser(addr: SocketAddr) -> Result { .await .context("Failed to create new browser page")?; - page.execute(AddBindingParams::new("READY")).await?; + for binding in BINDINGS { + page.execute(AddBindingParams::new(binding)).await?; + } let mut errors = page .event_listener::() @@ -365,7 +415,7 @@ async fn run_browser(addr: SocketAddr) -> Result { if is_debugging { let _ = page.evaluate( - r#"console.info("%cTurbopack tests:", "font-weight: bold;", "Waiting for READY to be signaled by page...");"#, + r#"console.info("%cTurbopack tests:", "font-weight: bold;", "Waiting for TURBOPACK_READY to be signaled by page...");"#, ) .await; } @@ -429,19 +479,9 @@ async fn run_browser(addr: SocketAddr) -> Result { errors_next = errors.next(); } event = &mut bindings_next => { - if event.is_some() { - if is_debugging { - let run_tests_msg = - "Entering debug mode. Run `await __jest__.run()` in the browser console to run tests."; - println!("\n\n{}", run_tests_msg); - page.evaluate(format!( - r#"console.info("%cTurbopack tests:", "font-weight: bold;", "{}");"#, - run_tests_msg - )) - .await?; - } else { - let value = page.evaluate("__jest__.run()").await?.into_value()?; - return Ok(value); + if let Some(event) = event { + if let Some(run_result) = handle_binding(&page, &*event, project_dir, is_debugging).await? { + return Ok(run_result); } } else { return Err(anyhow!("Binding events channel ended unexpectedly")); @@ -465,7 +505,7 @@ async fn run_browser(addr: SocketAddr) -> Result { } () = tokio::time::sleep(Duration::from_secs(60)) => { if !is_debugging { - return Err(anyhow!("Test timeout while waiting for READY")); + return Err(anyhow!("Test timeout while waiting for TURBOPACK_READY")); } } }; @@ -499,6 +539,87 @@ async fn get_mock_server_future(mock_dir: &Path) -> Result<(), String> { } } +async fn handle_binding( + page: &Page, + event: &EventBindingCalled, + project_dir: &Path, + is_debugging: bool, +) -> Result, anyhow::Error> { + match event.name.as_str() { + TURBOPACK_READY_BINDING => { + if is_debugging { + let run_tests_msg = "Entering debug mode. Run `await __jest__.run()` in the \ + browser console to run tests."; + println!("\n\n{}", run_tests_msg); + page.evaluate(format!( + r#"console.info("%cTurbopack tests:", "font-weight: bold;", "{}");"#, + run_tests_msg + )) + .await?; + } else { + page.evaluate_expression( + "(() => { __jest__.run().then((runResult) => \ + TURBOPACK_DONE(JSON.stringify(runResult))) })()", + ) + .await?; + } + } + TURBOPACK_DONE_BINDING => { + let run_result: JestRunResult = serde_json::from_str(&event.payload)?; + return Ok(Some(run_result)); + } + TURBOPACK_CHANGE_FILE_BINDING => { + let change_file: ChangeFileCommand = serde_json::from_str(&event.payload)?; + let path = Path::new(&change_file.path); + + // Ensure `change_file.path` can't escape the project directory. + let path = path + .components() + .filter(|c| match c { + std::path::Component::Normal(_) => true, + _ => false, + }) + .collect::(); + + let path: PathBuf = project_dir.join(path); + + let mut file_contents = std::fs::read_to_string(&path)?; + if !file_contents.contains(&change_file.find) { + page.evaluate(format!( + "__turbopackFileChanged({}, new Error({}));", + serde_json::to_string(&change_file.id)?, + serde_json::to_string(&format!( + "TURBOPACK_CHANGE_FILE: file {} does not contain {}", + path.display(), + &change_file.find + ))? + )) + .await?; + } else { + file_contents = file_contents.replace(&change_file.find, &change_file.replace_with); + std::fs::write(&path, file_contents)?; + + page.evaluate(format!( + "__turbopackFileChanged({});", + serde_json::to_string(&change_file.id)? + )) + .await?; + } + } + _ => {} + }; + Ok(None) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChangeFileCommand { + path: String, + id: String, + find: String, + replace_with: String, +} + #[turbo_tasks::value(shared)] struct TestIssueReporter { #[turbo_tasks(trace_ignore, debug_ignore)] diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration/next/hmr/simple/input/pages/index.tsx b/packages/next-swc/crates/next-dev-tests/tests/integration/next/hmr/simple/input/pages/index.tsx new file mode 100644 index 000000000000..de82256e7dbb --- /dev/null +++ b/packages/next-swc/crates/next-dev-tests/tests/integration/next/hmr/simple/input/pages/index.tsx @@ -0,0 +1,56 @@ +import { useRef } from 'react' +import { Harness, useTestHarness } from '@turbo/pack-test-harness' + +export default function Page() { + const iframeRef = useRef(null) + + useTestHarness((harness) => runTests(harness, iframeRef.current!)) + + return ( +