diff --git a/.prettierignore b/.prettierignore index 1217cd14..b493d4fd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,7 @@ /runtime/vendored /runtime/js/vendored target +/runtime/tests/js/typescript_fixtures/typescript_stack_trace.ts # Let's keep LICENSE.md in the same formatting as we use in other PL repositories LICENSE.md diff --git a/runtime/module_loader.rs b/runtime/module_loader.rs index dd1ea281..99c4a2bf 100644 --- a/runtime/module_loader.rs +++ b/runtime/module_loader.rs @@ -1,6 +1,8 @@ +use std::borrow::Cow; +use std::cell::RefCell; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; +use std::rc::Rc; use deno_ast::{MediaType, ParseParams}; use deno_core::anyhow::anyhow; @@ -21,7 +23,9 @@ use deno_core::anyhow::Result; pub struct ZinniaModuleLoader { module_root: Option, // Cache mapping file_name to source_code - code_cache: Arc>>, + code_cache: Rc>>, + // Cache mapping module_specifier string to source_map bytes + source_maps: Rc>>>, } impl ZinniaModuleLoader { @@ -34,7 +38,8 @@ impl ZinniaModuleLoader { Ok(Self { module_root, - code_cache: Arc::new(RwLock::new(HashMap::new())), + code_cache: Rc::new(RefCell::new(HashMap::new())), + source_maps: Rc::new(RefCell::new(HashMap::new())), }) } } @@ -79,6 +84,7 @@ impl ModuleLoader for ZinniaModuleLoader { let module_root = self.module_root.clone(); let maybe_referrer = maybe_referrer.cloned(); let code_cache = self.code_cache.clone(); + let source_maps = self.source_maps.clone(); let module_load = async move { let spec_str = module_specifier.as_str(); @@ -188,6 +194,10 @@ impl ModuleLoader for ZinniaModuleLoader { let code = read_file_to_string(&module_path).await?; + code_cache + .borrow_mut() + .insert(spec_str.to_string(), code.clone()); + let code = if should_transpile { let parsed = deno_ast::parse_module(ParseParams { specifier: module_specifier.clone(), @@ -198,7 +208,7 @@ impl ModuleLoader for ZinniaModuleLoader { maybe_syntax: None, }) .map_err(JsErrorBox::from_err)?; - parsed + let res = parsed .transpile( &deno_ast::TranspileOptions { imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Error, @@ -206,22 +216,26 @@ impl ModuleLoader for ZinniaModuleLoader { ..Default::default() }, &Default::default(), - &Default::default(), + &deno_ast::EmitOptions { + source_map: deno_ast::SourceMapOption::Separate, + inline_sources: true, + ..Default::default() + }, ) .map_err(JsErrorBox::from_err)? - .into_source() - .text + .into_source(); + + if let Some(source_map) = res.source_map { + source_maps + .borrow_mut() + .insert(module_specifier.to_string(), source_map.into_bytes()); + } + + res.text } else { code }; - code_cache - .write() - .map_err(|_| { - JsErrorBox::generic("Unexpected internal error: code_cache lock was poisoned") - })? - .insert(spec_str.to_string(), code.clone()); - let sandboxed_module_specifier = ModuleSpecifier::from_file_path(&sandboxed_path) .map_err(|_| { let msg = format!( @@ -245,9 +259,16 @@ impl ModuleLoader for ZinniaModuleLoader { ModuleLoadResponse::Async(module_load.boxed_local()) } + fn get_source_map(&self, specifier: &str) -> Option> { + self.source_maps + .borrow() + .get(specifier) + .map(|v| v.clone().into()) + } + fn get_source_mapped_source_line(&self, file_name: &str, line_number: usize) -> Option { log::debug!("get_source_mapped_source_line {file_name}:{line_number}"); - let code_cache = self.code_cache.read().ok()?; + let code_cache = self.code_cache.borrow(); let code = code_cache.get(file_name)?; // Based on Deno cli/module_loader.rs diff --git a/runtime/tests/js/typescript_fixtures/typescript_stack_trace.ts b/runtime/tests/js/typescript_fixtures/typescript_stack_trace.ts new file mode 100644 index 00000000..24a0365a --- /dev/null +++ b/runtime/tests/js/typescript_fixtures/typescript_stack_trace.ts @@ -0,0 +1,14 @@ +// This TypeScript-only block of code is removed during transpilation. A naive solution that removes +// the TypeScript code instead of replacing it with whitespace and does not apply source maps to error +// stack traces will lead to incorrect line numbers in error stack traces. +interface User { + name: string; + email: string; +} + +// The part `: Error` changes the source column number +// between the TypeScript original and the transpiled code. +// +// Throw the error so that the test can verify source code line & column numbers +// in the stack trace frames but also the source code of the line throwing the exception. +const error: Error = new Error(); throw error; diff --git a/runtime/tests/runtime_integration_tests.rs b/runtime/tests/runtime_integration_tests.rs index 83dce6df..1412d549 100644 --- a/runtime/tests/runtime_integration_tests.rs +++ b/runtime/tests/runtime_integration_tests.rs @@ -8,6 +8,7 @@ use std::rc::Rc; use anyhow::{anyhow, Context}; use deno_core::ModuleSpecifier; +use zinnia_runtime::fmt_errors::format_js_error; use zinnia_runtime::{any_and_jserrorbox_downcast_ref, CoreError, RecordingReporter}; use zinnia_runtime::{anyhow, deno_core, run_js_module, AnyError, BootstrapOptions}; @@ -94,6 +95,36 @@ js_tests!(ipfs_retrieval_tests); test_runner_tests!(passing_tests); test_runner_tests!(failing_tests expect_failure); +#[tokio::test] +async fn typescript_stack_trace_test() -> Result<(), AnyError> { + let (_, run_error) = run_js_test_file("typescript_fixtures/typescript_stack_trace.ts").await?; + let error = run_error.ok_or_else(|| { + anyhow!("The script was expected to throw an error. Success was reported instead.") + })?; + + if let Some(CoreError::Js(e)) = any_and_jserrorbox_downcast_ref::(&error) { + let actual_error = format_js_error(e); + // Strip ANSI codes (colors, styles) + let actual_error = console_static_text::ansi::strip_ansi_codes(&actual_error); + // Replace current working directory in stack trace file paths with a fixed placeholder + let cwd_url = ModuleSpecifier::from_file_path(std::env::current_dir().unwrap()).unwrap(); + let actual_error = actual_error.replace(cwd_url.as_str(), "file:///project-root"); + // Normalize line endings to Unix style (LF only) + let actual_error = actual_error.replace("\r\n", "\n"); + + let expected_error = r#" +Uncaught (in promise) Error +const error: Error = new Error(); throw error; + ^ + at file:///project-root/tests/js/typescript_fixtures/typescript_stack_trace.ts:14:22 +"#; + assert_eq!(actual_error.trim(), expected_error.trim()); + } else { + panic!("The script threw unexpected error: {}", error); + } + Ok(()) +} + #[tokio::test] async fn source_code_paths_when_no_module_root() -> Result<(), AnyError> { let (activities, run_error) =