-
Notifications
You must be signed in to change notification settings - Fork 2
fix: error stack traces in transpiled TypeScript #740
Changes from 4 commits
fe71fc0
24155c1
eb991a2
ff644be
822d7b7
f19cff8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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::<CoreError>(&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") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fn format_test_activities(events: &[String]) -> String { | |
| // Find all durations (e.g. `0ms` or `241ms`) | |
| let duration_regex = regex::Regex::new(r"\d+ms").unwrap(); | |
| // Find trailing whitespace on all lines. Note that a chunk can be multi-line! | |
| let eol_regex = regex::Regex::new(r" *\r?\n").unwrap(); | |
| let cwd_url = ModuleSpecifier::from_file_path(std::env::current_dir().unwrap()).unwrap(); | |
| events | |
| .iter() | |
| .map(|chunk| { | |
| // Strip ANSI codes (colors, styles) | |
| let chunk = console_static_text::ansi::strip_ansi_codes(chunk); | |
| // Remove `console.info: ` added by RecordingReporter. | |
| // Don't remove other `console` level prefixes, so that we can detect them. | |
| let chunk = match chunk.strip_prefix("console.info: ") { | |
| Some(remainder) => remainder, | |
| None => &chunk, | |
| }; | |
| // Replace current working directory in stack trace file paths with a fixed placeholder | |
| let chunk = chunk.replace(cwd_url.as_str(), "file:///project-root"); | |
| // Normalize durations | |
| let chunk = duration_regex.replace_all(&chunk, "XXms"); | |
| // Remove trailing whitespace before all EOLs | |
| let chunk = eol_regex.replace_all(&chunk, "\n"); | |
| // Format the line, adding back EOL after trimming whitespace at the end | |
| format!("{}\n", chunk.trim_end()) | |
| }) | |
| .collect::<Vec<String>>() | |
| .join("") | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In retrospect, it was a mistake to use Option<AnyError> here. I would like to rework this to return Result<(), AnyError> instead, but such refactoring is out of scope of this pull request.
@NikolasHaimerl, do you have a suggestion on how to best represent a type with the following variants in Rust?
- We cannot execute the JS/TS file ->
Err(AnyError) - We executed the JS/TS file and collected some logs in
Vec<String>. In addition to these logs, we want to provide:- The script executed cleanly ->
() - The script threw a runtime error ->
AnyError
- The script executed cleanly ->
Variants:
Err(AnyError)Ok((Ok(()), Vec<String>))Ok((Err(AnyError), Vec<String>))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about we wrap the logs inside the Anyhow context string?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you mean. We need to extract the logs from the results, so that we can assert what exactly was logged by the test runner on failure. Do you know how to extract one piece of a context from the Anyhow error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am leaning towards using Result<(Result<(), AnyError>, Vec<String>), AnyError>.
I.e. Result<ScriptResult, AnyError> where ScriptResult is a tuple (Result<(), AnyError>, Vec<String>).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it help to create a named struct ScriptResult?
Alternatively, maybe we can treat the case "we cannot execute the script" the same way as "the script threw an error".
That will give us (Result<(), AnyError>, Vec<String>)
- We cannot execute the JS/TS file ->
(Err(AnyError), vec![]) - The script executed cleanly ->
(Ok(()), logs) - The script threw an error ->
(Err(AnyError), logs)
WDYT?
I'd also like to make this refactoring as a follow-up, after I land the remaining two pull requests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you mean. Ideally I would like to avoid having a result inside a result. We could use enums as error types to give us specific information about which error was caused by the test runner. Why do we not unwrap an error once it occurs? What is the benefit of propagating it upwards if it is only used in testing?
Usually, in Rust testing, I would expect Errors to be unwrapped as they occur within the testing suite. Error propagation, I would usually only do in production.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense.
Why do we not unwrap an error once it occurs?
In some tests, we want to assert that execution of the JS code threw an error and that the JS code produced the expected logs.
What is the benefit of propagating it upwards if it is only used in testing?
Usually, in Rust testing, I would expect Errors to be unwrapped as they occur within the testing suite. Error propagation, I would usually only do in production.
I find it easier when all test helpers use error propagation instead of unwrapping, because that way I don't have to decide in each test helper which error-handling strategy is appropriate.
Let's continue this discussion in #752
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the module loader exclusively single threaded?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great question! I am not sure, TBH.
We are running the Deno runtime on the current thread, see here:
zinnia/daemon/main.rs
Line 21 in 2d40376
zinnia/cli/main.rs
Line 17 in 2d40376
I think that means all async tasks will be executed on the current thread too, including the asynchronous work of the module loader.
Deno uses
Rc<RefCell<...>>in their example:https://github.com/denoland/deno_core/blob/0.343.0/core/examples/ts_module_loader.rs
My knowledge of async Rust is very limited, I don't understand this deeply enough. What would you suggest to use?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see, well
Rc<RefCell<...>>is used in a single-threaded environment, since Deno is using that as an example, I would assume it is single threaded. If we were to use multithreaded environments, I'd rather go for aArc<Mutex<...>>type.