diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex
index 0e6f56887..3b8e758a2 100644
--- a/lib/livebook_web/router.ex
+++ b/lib/livebook_web/router.ex
@@ -16,6 +16,17 @@ defmodule LivebookWeb.Router do
plug LivebookWeb.UserPlug
end
+ pipeline :js_output_assets do
+ plug :put_secure_browser_headers
+ end
+
+ scope "/", LivebookWeb do
+ pipe_through [:js_output_assets]
+
+ get "/sessions/assets/:hash/*file_parts", SessionController, :show_cached_asset
+ get "/sessions/:id/assets/:hash/*file_parts", SessionController, :show_asset
+ end
+
live_session :default, on_mount: LivebookWeb.CurrentUserHook do
scope "/", LivebookWeb do
pipe_through [:browser, :auth]
diff --git a/test/livebook/notebook_test.exs b/test/livebook/notebook_test.exs
index 9f1c69255..4b2c251db 100644
--- a/test/livebook/notebook_test.exs
+++ b/test/livebook/notebook_test.exs
@@ -254,4 +254,24 @@ defmodule Livebook.NotebookTest do
}
end
end
+
+ describe "find_asset_info/2" do
+ test "returns asset info matching the given type if found" do
+ assets_info = %{archive: "/path/to/archive.tar.gz", hash: "abcd", js_path: "main.js"}
+ js_info = %{assets: assets_info}
+ output = {:js_static, js_info, %{}}
+
+ notebook = %{
+ Notebook.new()
+ | sections: [%{Section.new() | cells: [%{Cell.new(:elixir) | outputs: [output]}]}]
+ }
+
+ assert ^assets_info = Notebook.find_asset_info(notebook, "abcd")
+ end
+
+ test "returns nil if no matching info is found" do
+ notebook = Notebook.new()
+ assert Notebook.find_asset_info(notebook, "abcd") == nil
+ end
+ end
end
diff --git a/test/livebook/runtime/erl_dist/runtime_server_test.exs b/test/livebook/runtime/erl_dist/runtime_server_test.exs
index dbee36f1a..a06cbd457 100644
--- a/test/livebook/runtime/erl_dist/runtime_server_test.exs
+++ b/test/livebook/runtime/erl_dist/runtime_server_test.exs
@@ -168,6 +168,17 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServerTest do
end
end
+ describe "read_file/2" do
+ test "returns file contents when the file exists", %{pid: pid} do
+ assert {:ok, _} = RuntimeServer.read_file(pid, __ENV__.file)
+ end
+
+ test "returns an error when the file does not exist", %{pid: pid} do
+ assert {:error, "no such file or directory"} =
+ RuntimeServer.read_file(pid, "/definitly_non_existent/file/path")
+ end
+ end
+
test "notifies the owner when an evaluator goes down", %{pid: pid} do
code = """
spawn_link(fn -> Process.exit(self(), :kill) end)
diff --git a/test/livebook/unique_task_test.exs b/test/livebook/unique_task_test.exs
new file mode 100644
index 000000000..721a82f04
--- /dev/null
+++ b/test/livebook/unique_task_test.exs
@@ -0,0 +1,66 @@
+defmodule Livebook.UniqueTaskTest do
+ use ExUnit.Case, async: true
+
+ alias Livebook.UniqueTask
+
+ test "run/2 only awaits existing function call when the given key is taken" do
+ parent = self()
+
+ fun = fn ->
+ send(parent, {:ping_from_task, self()})
+
+ receive do
+ :pong -> :ok
+ end
+ end
+
+ spawn_link(fn ->
+ result = UniqueTask.run("key1", fun)
+ send(parent, {:result1, result})
+ end)
+
+ spawn_link(fn ->
+ result = UniqueTask.run("key1", fun)
+ send(parent, {:result2, result})
+ end)
+
+ assert_receive {:ping_from_task, task_pid}
+ refute_receive {:ping_from_task, _other_task_pid}, 5
+ # The function should be evaluated only once
+ send(task_pid, :pong)
+
+ assert_receive {:result1, :ok}
+ assert_receive {:result2, :ok}
+ end
+
+ test "run/2 runs functions in parallel when different have different keys" do
+ parent = self()
+
+ fun = fn ->
+ send(parent, {:ping_from_task, self()})
+
+ receive do
+ :pong -> :ok
+ end
+ end
+
+ spawn_link(fn ->
+ result = UniqueTask.run("key1", fun)
+ send(parent, {:result1, result})
+ end)
+
+ spawn_link(fn ->
+ result = UniqueTask.run("key2", fun)
+ send(parent, {:result2, result})
+ end)
+
+ assert_receive {:ping_from_task, task1_pid}
+ assert_receive {:ping_from_task, task2_pid}
+
+ send(task1_pid, :pong)
+ send(task2_pid, :pong)
+
+ assert_receive {:result1, :ok}
+ assert_receive {:result2, :ok}
+ end
+end
diff --git a/test/livebook_web/controllers/session_controller_test.exs b/test/livebook_web/controllers/session_controller_test.exs
index c5561ddef..3900b128c 100644
--- a/test/livebook_web/controllers/session_controller_test.exs
+++ b/test/livebook_web/controllers/session_controller_test.exs
@@ -139,4 +139,80 @@ defmodule LivebookWeb.SessionControllerTest do
Session.close(session.pid)
end
end
+
+ describe "show_asset" do
+ test "fetches assets and redirects to the session-less path", %{conn: conn} do
+ %{notebook: notebook, hash: hash} = notebook_with_js_output()
+
+ conn = start_session_and_request_asset(conn, notebook, hash)
+
+ assert redirected_to(conn, 301) ==
+ Routes.session_path(conn, :show_cached_asset, hash, ["main.js"])
+ end
+
+ test "skips the session if assets are in cache", %{conn: conn} do
+ %{notebook: notebook, hash: hash} = notebook_with_js_output()
+ # Fetch the assets for the first time
+ conn = start_session_and_request_asset(conn, notebook, hash)
+
+ # Use nonexistent session, so any communication would fail
+ random_session_id = Livebook.Utils.random_node_aware_id()
+
+ conn =
+ get(conn, Routes.session_path(conn, :show_asset, random_session_id, hash, ["main.js"]))
+
+ assert redirected_to(conn, 301) ==
+ Routes.session_path(conn, :show_cached_asset, hash, ["main.js"])
+ end
+ end
+
+ describe "show_cached_asset" do
+ test "returns not found when no matching assets are in the cache", %{conn: conn} do
+ %{notebook: _notebook, hash: hash} = notebook_with_js_output()
+
+ conn = get(conn, Routes.session_path(conn, :show_cached_asset, hash, ["main.js"]))
+
+ assert conn.status == 404
+ assert conn.resp_body == "Not found"
+ end
+
+ test "returns the requestes asset if available in cache", %{conn: conn} do
+ %{notebook: notebook, hash: hash} = notebook_with_js_output()
+ # Fetch the assets for the first time
+ conn = start_session_and_request_asset(conn, notebook, hash)
+
+ conn = get(conn, Routes.session_path(conn, :show_cached_asset, hash, ["main.js"]))
+
+ assert conn.status == 200
+ assert "export function init(" <> _ = conn.resp_body
+ end
+ end
+
+ defp start_session_and_request_asset(conn, notebook, hash) do
+ {:ok, session} = Sessions.create_session(notebook: notebook)
+ # We need runtime in place to actually copy the archive
+ Session.connect_runtime(session.pid, Livebook.Runtime.NoopRuntime.new())
+
+ conn = get(conn, Routes.session_path(conn, :show_asset, session.id, hash, ["main.js"]))
+
+ Session.close(session.pid)
+
+ conn
+ end
+
+ defp notebook_with_js_output() do
+ archive_path = Path.expand("../../support/assets.tar.gz", __DIR__)
+ hash = "test-" <> Livebook.Utils.random_id()
+ assets_info = %{archive_path: archive_path, hash: hash, js_path: "main.js"}
+ output = {:js_static, %{assets: assets_info}, %{}}
+
+ notebook = %{
+ Notebook.new()
+ | sections: [
+ %{Notebook.Section.new() | cells: [%{Notebook.Cell.new(:elixir) | outputs: [output]}]}
+ ]
+ }
+
+ %{notebook: notebook, hash: hash}
+ end
end
diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs
index ad5c991b2..f0e76c6f8 100644
--- a/test/livebook_web/live/session_live_test.exs
+++ b/test/livebook_web/live/session_live_test.exs
@@ -290,6 +290,46 @@ defmodule LivebookWeb.SessionLiveTest do
assert render(view) =~ "Dynamic output in frame"
end
+
+ test "static js output sends the embedded data to the client", %{conn: conn, session: session} do
+ js_info = %{assets: %{archive_path: "", hash: "abcd", js_path: "main.js"}}
+ js_static_output = {:js_static, js_info, [1, 2, 3]}
+
+ section_id = insert_section(session.pid)
+ cell_id = insert_text_cell(session.pid, section_id, :elixir)
+ # Evaluate the cell
+ Session.queue_cell_evaluation(session.pid, cell_id)
+ # Send an additional output
+ send(session.pid, {:evaluation_output, cell_id, js_static_output})
+
+ {:ok, view, _} = live(conn, "/sessions/#{session.id}")
+
+ assert_push_event(view, "js_output:" <> _, %{"data" => [1, 2, 3]})
+ end
+
+ test "dynamic js output loads initial data from the widget server",
+ %{conn: conn, session: session} do
+ widget_pid =
+ spawn(fn ->
+ receive do
+ {:connect, pid, %{}} -> send(pid, {:connect_reply, [1, 2, 3]})
+ end
+ end)
+
+ js_info = %{assets: %{archive_path: "", hash: "abcd", js_path: "main.js"}}
+ js_dynamic_output = {:js_dynamic, js_info, widget_pid}
+
+ section_id = insert_section(session.pid)
+ cell_id = insert_text_cell(session.pid, section_id, :elixir)
+ # Evaluate the cell
+ Session.queue_cell_evaluation(session.pid, cell_id)
+ # Send an additional output
+ send(session.pid, {:evaluation_output, cell_id, js_dynamic_output})
+
+ {:ok, view, _} = live(conn, "/sessions/#{session.id}")
+
+ assert_push_event(view, "js_output:" <> _, %{"data" => [1, 2, 3]})
+ end
end
describe "runtime settings" do
diff --git a/test/support/assets.tar.gz b/test/support/assets.tar.gz
new file mode 100644
index 000000000..3ceaf1873
Binary files /dev/null and b/test/support/assets.tar.gz differ
diff --git a/test/support/noop_runtime.ex b/test/support/noop_runtime.ex
index 645f71c7f..01373b3e2 100644
--- a/test/support/noop_runtime.ex
+++ b/test/support/noop_runtime.ex
@@ -16,6 +16,13 @@ defmodule Livebook.Runtime.NoopRuntime do
def drop_container(_, _), do: :ok
def handle_intellisense(_, _, _, _, _), do: :ok
def duplicate(_), do: {:ok, Livebook.Runtime.NoopRuntime.new()}
- def standalone?(_runtime), do: false
+ def standalone?(_), do: false
+
+ def read_file(_, path) do
+ case File.read(path) do
+ {:ok, content} -> {:ok, content}
+ {:error, posix} -> {:error, posix |> :file.format_error() |> List.to_string()}
+ end
+ end
end
end