Repository: jstz-dev/jstz
Pull request #863: feat(runtime): add support for console
Author: login: zcabter
Created: 2025-03-10T14:08:56Z | Merged: 2025-03-24T13:26:40Z

# Context
Adds `console` support. It is implemented using the existing `deno_console` extension.
<!-- Why is this change required? What problem does it solve? -->

<!-- If it closes an Asana Task, please link to the task here. -->
<!-- **Related Tasks**: [Task name](Task url) -->

# Description
* To add console support, we register an extension that provides the "print" capability, in our case `debug_msg`, to `deno_console` extension. `HostRuntime` is exposed through borrowing an instance of it from the pre-seeded `op_state`
* `extension!` creates a new Rust module with `name` that includes the `Extension` type and logic needed to instantiate it. To register the console, we create a hard dependency on `deno_console` then attach an instance of console to `globalThis` in js. We need to make the `extension` aware of the js files and execute the initializer by settings `esm` and `esm_entry_point` - without this step, the extension will not be registered to the global object.  Lastly, we register the extensions by including it in the `extensions` field of `RuntimeOptions` on init

<!-- Describe your changes in detail. -->

<!-- If this PR has dependencies, please link them here. -->
<!-- **Dependencies**: -->

# Manually testing the PR [WIP]
`cargo nextest run -p jstz_runtime console`
<!-- Describe how reviewers and approvers can test this PR. -->



Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 4602ced32..1f531f87c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1120,6 +1120,15 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "deno_console"
+version = "0.190.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94352b8d75c288a26ef748ad0ddae07e181109374a02c547850f96eef76b5389"
+dependencies = [
+ "deno_core",
+]
+
 [[package]]
 name = "deno_core"
 version = "0.336.0"
@@ -2771,6 +2780,7 @@ name = "jstz_runtime"
 version = "0.1.0-alpha.0"
 dependencies = [
  "anyhow",
+ "deno_console",
  "deno_core",
  "deno_error",
  "derive_more",
diff --git a/Cargo.toml b/Cargo.toml
index 325dbb783..dd70362a0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -50,6 +50,7 @@ console = "0.15.8"
 crossterm = "0.27"
 ctrlc = "3.4.2"
 daemonize = "0.5.0"
+deno_console = "0.190.0"
 deno_core = "0.336.0"
 deno_error = "0.5.6"
 deno_fs = "0.100.0"
diff --git a/crates/jstz_runtime/Cargo.toml b/crates/jstz_runtime/Cargo.toml
index 7afd08465..dcb06934f 100644
--- a/crates/jstz_runtime/Cargo.toml
+++ b/crates/jstz_runtime/Cargo.toml
@@ -11,11 +11,10 @@ license-file.workspace = true
 description = "Javascript runtime for Jstz"
 
 [dependencies]
+deno_console.workspace = true
 deno_core.workspace = true
-
 jstz_core = { path = "../jstz_core" }
 jstz_crypto = { path = "../jstz_crypto" }
-
 tezos-smart-rollup.workspace = true
 tezos-smart-rollup-host.workspace = true
 
diff --git a/crates/jstz_runtime/src/jstz_console/console.js b/crates/jstz_runtime/src/jstz_console/console.js
new file mode 100644
index 000000000..97d32e9b5
--- /dev/null
+++ b/crates/jstz_runtime/src/jstz_console/console.js
@@ -0,0 +1,10 @@
+import * as console from "ext:deno_console/01_console.js";
+
+Object.defineProperty(globalThis, "console", {
+  value: new console.Console((msg, level) =>
+    globalThis.Deno.core.ops.op_debug_msg(msg, level),
+  ),
+  enumerable: false,
+  configurable: true,
+  writable: true,
+});
diff --git a/crates/jstz_runtime/src/jstz_console/mod.rs b/crates/jstz_runtime/src/jstz_console/mod.rs
new file mode 100644
index 000000000..d25bd97c2
--- /dev/null
+++ b/crates/jstz_runtime/src/jstz_console/mod.rs
@@ -0,0 +1,89 @@
+use crate::runtime::Protocol;
+use deno_core::*;
+use tezos_smart_rollup::prelude::debug_msg;
+
+// Level Description
+//  0    debug
+//  1    log, info
+//  2    warn
+//  3    error
+#[op2(fast)]
+pub fn op_debug_msg(op_state: &mut OpState, #[string] msg: &str, level: u32) {
+    let proto = op_state.borrow_mut::<Protocol>();
+    debug_msg!(proto.host, "{} {}", level_to_symbol(level), msg);
+}
+
+fn level_to_symbol(level: u32) -> &'static str {
+    match level {
+        0 => "[DEBUG]",
+        1 => "[INFO]",
+        2 => "[WARN]",
+        _ => "[ERROR]",
+    }
+}
+
+extension!(
+    jstz_console,
+    deps = [deno_console],
+    ops = [op_debug_msg],
+    esm_entry_point = "ext:jstz_console/console.js",
+    esm = [dir "src/jstz_console", "console.js"],
+);
+
+#[cfg(test)]
+mod test {
+
+    use crate::init_test_setup;
+
+    #[test]
+    fn console_log() {
+        init_test_setup!(runtime, host, tx, sink, address);
+        let code = r#"console.log("hello")"#;
+        runtime.execute(code).unwrap();
+        assert_eq!(sink.to_string(), "[INFO] hello\n");
+    }
+
+    #[test]
+    fn console_info() {
+        init_test_setup!(runtime, host, tx, sink, address);
+        let code = r#"console.info("hello")"#;
+        runtime.execute(code).unwrap();
+        assert_eq!(sink.to_string(), "[INFO] hello\n");
+    }
+
+    #[test]
+    fn console_warn() {
+        init_test_setup!(runtime, host, tx, sink, address);
+        let code = r#"console.warn("hello")"#;
+        runtime.execute(code).unwrap();
+        assert_eq!(sink.to_string(), "[WARN] hello\n");
+    }
+
+    #[test]
+    fn console_error() {
+        init_test_setup!(runtime, host, tx, sink, address);
+        let code = r#"console.error("hello")"#;
+        runtime.execute(code).unwrap();
+        assert_eq!(sink.to_string(), "[ERROR] hello\n");
+    }
+
+    #[test]
+    fn console_debug() {
+        init_test_setup!(runtime, host, tx, sink, address);
+        let code = r#"console.debug("hello")"#;
+        runtime.execute(code).unwrap();
+        assert_eq!(sink.to_string(), "[DEBUG] hello\n");
+    }
+
+    #[test]
+    fn console_js_types() {
+        init_test_setup!(runtime, host, tx, sink, address);
+        let code = r#"
+            console.info(123)
+            console.info(false)
+            console.info({ message: "abc" })
+        "#;
+        runtime.execute(code).unwrap();
+        assert_eq!(sink.to_string(), "[INFO] \u{1b}[33m123\u{1b}[39m\n[INFO] \u{1b}[33mfalse\u{1b}[39m\n[INFO] { message: \u{1b}[32m\"abc\"\u{1b}[39m }\n");
+    }
+}
diff --git a/crates/jstz_runtime/src/lib.rs b/crates/jstz_runtime/src/lib.rs
index 988d04cfc..1db3ae040 100644
--- a/crates/jstz_runtime/src/lib.rs
+++ b/crates/jstz_runtime/src/lib.rs
@@ -1,3 +1,4 @@
+mod jstz_console;
 pub mod runtime;
 
 pub use runtime::JstzRuntime;
@@ -5,8 +6,29 @@ pub use runtime::JstzRuntime;
 #[cfg(test)]
 mod test_utils {
 
+    use std::fmt::Display;
+
     use tezos_smart_rollup_mock::MockHost;
 
+    #[allow(clippy::box_collection)]
+    pub struct Sink(pub Box<Vec<u8>>);
+
+    impl Sink {
+        #[allow(unused)]
+        pub fn lines(&self) -> Vec<String> {
+            self.to_string()
+                .split("\n")
+                .map(ToString::to_string)
+                .collect()
+        }
+    }
+
+    impl Display for Sink {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            write!(f, "{}", String::from_utf8_lossy(self.0.as_ref()))
+        }
+    }
+
     #[allow(unused)]
     #[allow(clippy::box_collection)]
     pub fn init_mock_host() -> (Box<Vec<u8>>, MockHost) {
@@ -20,4 +42,28 @@ mod test_utils {
 
         (sink, host)
     }
+
+    // Initializes components required for testing.
+    //
+    // Because we use hacks to create 'static lifetime references, the component instances
+    // must not be dropped. We do so by instantiating the instances in the local scope
+    #[macro_export]
+    macro_rules! init_test_setup {
+        ($runtime:ident, $host:ident, $tx:ident, $sink:ident, $address:ident) => {
+            let mut $sink: Box<Vec<u8>> = Box::default();
+            let mut $host = tezos_smart_rollup_mock::MockHost::default();
+            $host.set_debug_handler(unsafe {
+                std::mem::transmute::<&mut std::vec::Vec<u8>, &'static mut Vec<u8>>(
+                    $sink.as_mut(),
+                )
+            });
+            let $address =
+                <jstz_crypto::smart_function_hash::SmartFunctionHash as jstz_crypto::hash::Hash>::from_base58("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton")
+                    .unwrap();
+            let mut $tx = jstz_core::kv::Transaction::default();
+            $tx.begin();
+            let $runtime = $crate::JstzRuntime::new(&mut $host, &mut $tx, $address.clone(), None);
+            let $sink = $crate::test_utils::Sink($sink);
+        };
+    }
 }
diff --git a/crates/jstz_runtime/src/runtime.rs b/crates/jstz_runtime/src/runtime.rs
index 3db09d5cc..ab5e2352c 100644
--- a/crates/jstz_runtime/src/runtime.rs
+++ b/crates/jstz_runtime/src/runtime.rs
@@ -1,3 +1,4 @@
+use deno_core::error::CoreError;
 use deno_core::*;
 use jstz_core::host::HostRuntime;
 use jstz_core::host::JsHostRuntime;
@@ -7,9 +8,8 @@ use serde::Deserialize;
 use std::ops::Deref;
 use std::ops::DerefMut;
 
-fn init_extenions() -> Vec<Extension> {
-    vec![]
-}
+use crate::jstz_console::jstz_console;
+use deno_console::deno_console;
 
 /// [`JstzRuntime`] manages the [`JsRuntime`] state. It is also
 /// provides [`JsRuntime`] with the instiatiated [`HostRuntime`]
@@ -31,7 +31,7 @@ impl JstzRuntime {
 
     /// Creates a new [`JstzRuntime`] with protocol extensions registered
     /// and an instance of Protocol exposed in the [`OpState`]
-    pub fn init(
+    pub fn new(
         hrt: &mut impl HostRuntime,
         tx: &mut Transaction,
         address: SmartFunctionHash,
@@ -49,9 +49,9 @@ impl JstzRuntime {
 
     /// Executes traditional, non-ECMAScript-module JavaScript code, ignoring
     /// its result
-    pub fn execute(mut self, code: &str) -> Option<()> {
-        self.execute_script("jstz://run", code.to_string()).ok()?;
-        Some(())
+    pub fn execute(mut self, code: &str) -> Result<()> {
+        self.execute_script("jstz://run", code.to_string())?;
+        Ok(())
     }
 
     /// Executes traditional, non-ECMAScript-module JavaScript code, parsing
@@ -60,13 +60,16 @@ impl JstzRuntime {
         &mut self,
         code: &str,
     ) -> Option<T> {
-        let value = self.execute_script("jstz://run", code.to_string()).unwrap();
+        let value = self.execute_script("jstz://run", code.to_string()).ok()?;
         let scope = &mut self.handle_scope();
         let local = v8::Local::new(scope, value);
-        serde_v8::from_v8::<T>(scope, local).ok()
+        let t = serde_v8::from_v8::<T>(scope, local).ok()?;
+        Some(t)
     }
 }
 
+type Result<T> = std::result::Result<T, CoreError>;
+
 impl Deref for JstzRuntime {
     type Target = JsRuntime;
 
@@ -114,3 +117,37 @@ macro_rules! init_ops_and_esm_extensions  {
         ]
     };
 }
+
+fn init_extenions() -> Vec<Extension> {
+    init_ops_and_esm_extensions!(deno_console, jstz_console)
+}
+
+#[cfg(test)]
+mod test {
+    use jstz_core::kv::Transaction;
+    use jstz_crypto::{hash::Hash, smart_function_hash::SmartFunctionHash};
+
+    use crate::{test_utils::init_mock_host, JstzRuntime};
+
+    #[test]
+    fn test_init_jstz_runtime() {
+        let address =
+            SmartFunctionHash::from_base58("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton")
+                .unwrap();
+        let (sink, mut host) = init_mock_host();
+        let tx = &mut Transaction::default();
+        tx.begin();
+        let jstz_runtime = JstzRuntime::new(&mut host, tx, address, None);
+
+        let code = r#"
+            console.log("hello");
+            console.log(42);
+        "#;
+
+        jstz_runtime.execute(code).unwrap();
+        assert_eq!(
+            String::from_utf8_lossy(&sink),
+            "[INFO] hello\n[INFO] \u{1b}[33m42\u{1b}[39m\n".to_string()
+        )
+    }
+}
diff --git a/crates/jstz_runtime/tests/wpt.rs b/crates/jstz_runtime/tests/wpt.rs
index f3bc2472a..24700b2fb 100644
--- a/crates/jstz_runtime/tests/wpt.rs
+++ b/crates/jstz_runtime/tests/wpt.rs
@@ -245,7 +245,7 @@ fn init_runtime() -> (JstzRuntime, Transaction, MockHost) {
     options
         .extensions
         .push(test_harness_api::init_ops_and_esm());
-    let mut runtime = JstzRuntime::init(&mut host, &mut tx, address, Some(options));
+    let mut runtime = JstzRuntime::new(&mut host, &mut tx, address, Some(options));
 
     let op_state = runtime.op_state();
     // Insert a blank report to be filled in by test cases


Reviews:
user: login: zcabter
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T13:14:44Z
user: login: johnyob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T20:51:23Z
user: login: zcabter
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T15:21:22Z
user: login: zcabter
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T15:26:18Z
user: login: huancheng-trili
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-14T10:21:23Z

Review comments:
diff_hunk: @@ -0,0 +1,10 @@
+import * as console from "ext:deno_console/01_console.js";
+
+Object.defineProperty(globalThis, "console", {
+  value: new console.Console((msg, level) =>
+    globalThis.Deno.core.ops.op_debug_msg(msg, level),
+  ),
+  enumerable: false,
+  configurable: true,
+  writable: true,
path: crates/jstz_proto/src/runtime/console/console.js
user: login: zcabter
body: Should be false
created_at: 2025-03-12T13:14:43Z
author_association: COLLABORATOR
path: crates/jstz_runtime/src/jstz_console/console.js
user: login: johnyob
body: I propose we follow Deno's naming for JS files to include a dependency tree depth. I think this would be `02_console.js`
created_at: 2025-03-12T20:51:23Z
author_association: COLLABORATOR
path: crates/jstz_runtime/src/jstz_console/console.js
user: login: zcabter
body: Hmm I don't quite understand the benefit for us since these files are referenced where the extension is defined.
Extension has a `deps` field that enforces extension dependencies at runtime. 
created_at: 2025-03-13T15:21:22Z
author_association: COLLABORATOR
path: crates/jstz_runtime/src/jstz_console/console.js
user: login: zcabter
body: I'll close this first for the sake of progressing but happy to revisit and update the js file names as needed.
created_at: 2025-03-13T15:26:18Z
author_association: COLLABORATOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-10T15:20:18Z
body: ## [Codecov](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev) Report
Attention: Patch coverage is `89.01099%` with `10 lines` in your changes missing coverage. Please review.
> Project coverage is 54.36%. Comparing base [(`1751299`)](https://app.codecov.io/gh/jstz-dev/jstz/commit/17512997603476d7833fd7f6d96653132c180d03?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev) to head [(`210136a`)](https://app.codecov.io/gh/jstz-dev/jstz/commit/210136a67f8d73506db2f3dff9710ad4820703ee?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev) | Patch % | Lines |
|---|---|---|
| [crates/jstz\_runtime/src/lib.rs](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?src=pr&el=tree&filepath=crates%2Fjstz_runtime%2Fsrc%2Flib.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev#diff-Y3JhdGVzL2pzdHpfcnVudGltZS9zcmMvbGliLnJz) | 33.33% | [6 Missing :warning: ](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev) |
| [crates/jstz\_runtime/src/runtime.rs](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?src=pr&el=tree&filepath=crates%2Fjstz_runtime%2Fsrc%2Fruntime.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev#diff-Y3JhdGVzL2pzdHpfcnVudGltZS9zcmMvcnVudGltZS5ycw==) | 86.66% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev) |

| [Files with missing lines](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev) | Coverage Δ | |
|---|---|---|
| [crates/jstz\_runtime/src/jstz\_console/mod.rs](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?src=pr&el=tree&filepath=crates%2Fjstz_runtime%2Fsrc%2Fjstz_console%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev#diff-Y3JhdGVzL2pzdHpfcnVudGltZS9zcmMvanN0el9jb25zb2xlL21vZC5ycw==) | `100.00% <100.00%> (ø)` | |
| [crates/jstz\_runtime/src/runtime.rs](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?src=pr&el=tree&filepath=crates%2Fjstz_runtime%2Fsrc%2Fruntime.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev#diff-Y3JhdGVzL2pzdHpfcnVudGltZS9zcmMvcnVudGltZS5ycw==) | `82.71% <86.66%> (+82.71%)` | :arrow_up: |
| [crates/jstz\_runtime/src/lib.rs](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?src=pr&el=tree&filepath=crates%2Fjstz_runtime%2Fsrc%2Flib.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev#diff-Y3JhdGVzL2pzdHpfcnVudGltZS9zcmMvbGliLnJz) | `70.00% <33.33%> (+70.00%)` | :arrow_up: |

... and [4 files with indirect coverage changes](https://app.codecov.io/gh/jstz-dev/jstz/pull/863/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev)

------

[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?dropdown=coverage&src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev). Last update [1751299...210136a](https://app.codecov.io/gh/jstz-dev/jstz/pull/863?dropdown=coverage&src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jstz-dev).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: huancheng-trili
created_at: 2025-03-10T16:21:32Z
body: Can you explain in the description how this works? Does module registration now needs a JS module as well?

And does `import("console")` work?
author_association: COLLABORATOR
user: login: zcabter
created_at: 2025-03-12T13:26:34Z
body: > Can you explain in the description how this works? Does module registration now needs a JS module as well?

I've updated the description. 

> And does `import("console")` work?

Its callable but the promise won't resolve because we don't support async at the top level yet. Will be done as part of module loading. But if it did resolve, it will probably fail because module loading support is not enabled

author_association: COLLABORATOR