Remove old output formats (#876)

* Remove old outputs

* Remove other occurrences
This commit is contained in:
Jonatan Kłosko 2022-01-17 13:24:59 +01:00 committed by GitHub
parent fa67c3c567
commit c57e5448b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 94 additions and 1914 deletions

View file

@ -19,7 +19,6 @@ import ScrollOnUpdate from "./scroll_on_update";
import VirtualizedLines from "./virtualized_lines";
import UserForm from "./user_form";
import EditorSettings from "./editor_settings";
import VegaLite from "./vega_lite";
import Timer from "./timer";
import MarkdownRenderer from "./markdown_renderer";
import Highlight from "./highlight";
@ -40,7 +39,6 @@ const hooks = {
VirtualizedLines,
UserForm,
EditorSettings,
VegaLite,
Timer,
MarkdownRenderer,
Highlight,

View file

@ -1,114 +0,0 @@
import { getAttributeOrThrow } from "../lib/attribute";
import { throttle } from "../lib/utils";
/**
* Dynamically imports the vega-related modules.
*/
function importVega() {
return import(
/* webpackChunkName: "vega" */
"./vega"
);
}
// See https://github.com/vega/vega-lite/blob/b61b13c2cbd4ecde0448544aff6cdaea721fd22a/src/compile/data/assemble.ts#L228-L231
const DEFAULT_DATASET_NAME = "source_0";
/**
* A hook used to render graphics according to the given
* Vega-Lite specification.
*
* The hook expects a `vega_lite:<id>:init` event with `{ spec }` payload,
* where `spec` is the graphic definition as an object.
*
* Later `vega_lite:<id>:push` events may be sent with `{ data, dataset, window }` payload,
* to dynamically update the underlying data.
*
* Configuration:
*
* * `data-id` - plot id
*
*/
const VegaLite = {
mounted() {
this.props = getProps(this);
this.state = {
container: null,
viewPromise: null,
};
this.state.container = document.createElement("div");
this.el.appendChild(this.state.container);
this.handleEvent(`vega_lite:${this.props.id}:init`, ({ spec }) => {
if (!spec.data) {
spec.data = { values: [] };
}
this.state.viewPromise = importVega()
.then(({ vegaEmbed }) => {
return vegaEmbed(this.state.container, spec, {});
})
.then((result) => result.view)
.catch((error) => {
const message = `Failed to render the given Vega-Lite specification, got the following error:\n\n ${error.message}\n\nMake sure to check for typos.`;
this.state.container.innerHTML = `
<div class="text-red-600 whitespace-pre-wrap">${message}</div>
`;
});
});
const throttledResize = throttle((view) => view.resize(), 1_000);
this.handleEvent(
`vega_lite:${this.props.id}:push`,
({ data, dataset, window }) => {
dataset = dataset || DEFAULT_DATASET_NAME;
this.state.viewPromise.then((view) => {
const currentData = view.data(dataset);
buildChangeset(currentData, data, window).then((changeset) => {
// Schedule resize after the run finishes
throttledResize(view);
view.change(dataset, changeset).run();
});
});
}
);
},
updated() {
this.props = getProps(this);
},
destroyed() {
if (this.state.viewPromise) {
this.state.viewPromise.then((view) => view.finalize());
}
},
};
function getProps(hook) {
return {
id: getAttributeOrThrow(hook.el, "data-id"),
};
}
function buildChangeset(currentData, newData, window) {
return importVega().then(({ vega }) => {
if (window === 0) {
return vega.changeset().remove(currentData);
} else if (window) {
const toInsert = newData.slice(-window);
const freeSpace = Math.max(window - toInsert.length, 0);
const toRemove = currentData.slice(0, -freeSpace);
return vega.changeset().remove(toRemove).insert(toInsert);
} else {
return vega.changeset().insert(newData);
}
});
}
export default VegaLite;

View file

@ -1,4 +0,0 @@
import * as vega from "vega";
import vegaEmbed from "vega-embed";
export { vega, vegaEmbed };

1323
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
{
"private": true,
"license": "MIT",
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch",
@ -38,10 +37,7 @@
"topbar": "^1.0.1",
"unified": "^10.1.0",
"unist-util-remove-position": "^4.0.1",
"unist-util-visit": "^4.0.0",
"vega": "^5.20.2",
"vega-embed": "^6.18.1",
"vega-lite": "^5.1.0"
"unist-util-visit": "^4.0.0"
},
"devDependencies": {
"@babel/core": "^7.14.0",

View file

@ -170,10 +170,6 @@ defmodule Livebook.LiveMarkdown.Export do
[delimiter, "output\n", text, "\n", delimiter]
end
defp render_output({:vega_lite_static, spec}, _ctx) do
["```", "vega-lite\n", Jason.encode!(spec), "\n", "```"]
end
defp render_output(
{:js, %{export: %{info_string: info_string, key: key}, ref: ref}},
ctx

View file

@ -187,16 +187,6 @@ defmodule Livebook.LiveMarkdown.Import do
take_outputs(ast, [{:text, output} | outputs])
end
defp take_outputs(
[{"pre", _, [{"code", [{"class", "vega-lite"}], [output], %{}}], %{}} | ast],
outputs
) do
case Jason.decode(output) do
{:ok, spec} -> take_outputs(ast, [{:vega_lite_static, spec} | outputs])
_ -> take_outputs(ast, outputs)
end
end
defp take_outputs(ast, outputs), do: {outputs, ast}
# Builds a notebook from the list of elements obtained in the previous step.

View file

@ -34,16 +34,8 @@ defmodule Livebook.Notebook.Cell.Elixir do
| {:markdown, binary()}
# A raw image in the given format
| {:image, content :: binary(), mime_type :: binary()}
# Vega-Lite graphic
| {:vega_lite_static, spec :: map()}
# Vega-Lite graphic with dynamic data
| {:vega_lite_dynamic, widget_process :: pid()}
# JavaScript powered output
| {:js, info :: map()}
# Interactive data table
| {:table_dynamic, widget_process :: pid()}
# Dynamic wrapper for static output
| {:frame_dynamic, widget_process :: pid()}
# Outputs placeholder
| {:frame, outputs :: list(output()), info :: map()}
# An input field

View file

@ -201,7 +201,7 @@ install the [Kino](https://github.com/livebook-dev/kino) library:
```elixir
Mix.install(
[
{:kino, "~> 0.4.1"}
{:kino, github: "livebook-dev/kino"}
],
consolidate_protocols: false
)

View file

@ -53,7 +53,7 @@ instance, otherwise the command below will fail.
```elixir
Mix.install([
{:kino, "~> 0.4.1"}
{:kino, github: "livebook-dev/kino"}
])
```

View file

@ -11,7 +11,7 @@ directly, but it is required to render VegaLite:
```elixir
Mix.install([
{:vega_lite, "~> 0.1.2"},
{:kino, "~> 0.4.1"}
{:kino, github: "livebook-dev/kino"}
])
```

View file

@ -10,7 +10,7 @@ and interact with them.
```elixir
Mix.install([
{:kino, "~> 0.4.1"},
{:kino, github: "livebook-dev/kino"},
{:vega_lite, "~> 0.1.2"}
])
```

View file

@ -17,7 +17,7 @@ the visualization and interactions.
```elixir
Mix.install([
{:kino, "~> 0.4.1"}
{:kino, github: "livebook-dev/kino"}
])
```

View file

@ -14,7 +14,7 @@ so let's add `:vega_lite` and `:kino` for that.
```elixir
Mix.install([
{:vega_lite, "~> 0.1.2"},
{:kino, "~> 0.4.1"}
{:kino, github: "livebook-dev/kino"}
])
```

View file

@ -1,6 +1,8 @@
defmodule LivebookWeb.Output do
use Phoenix.Component
alias LivebookWeb.Output
@doc """
Renders a list of cell outputs.
"""
@ -34,68 +36,31 @@ defmodule LivebookWeb.Output do
defp render_output({:stdout, text}, %{id: id}) do
text = if(text == :__pruned__, do: nil, else: text)
live_component(LivebookWeb.Output.StdoutComponent, id: id, text: text, follow: true)
live_component(Output.StdoutComponent, id: id, text: text, follow: true)
end
defp render_output({:text, text}, %{id: id}) do
assigns = %{id: id, text: text}
~H"""
<LivebookWeb.Output.TextComponent.render id={@id} content={@text} follow={false} />
<Output.TextComponent.render id={@id} content={@text} follow={false} />
"""
end
defp render_output({:markdown, markdown}, %{id: id}) do
live_component(LivebookWeb.Output.MarkdownComponent, id: id, content: markdown)
live_component(Output.MarkdownComponent, id: id, content: markdown)
end
defp render_output({:image, content, mime_type}, %{id: id}) do
assigns = %{id: id, content: content, mime_type: mime_type}
~H"""
<LivebookWeb.Output.ImageComponent.render content={@content} mime_type={@mime_type} />
<Output.ImageComponent.render content={@content} mime_type={@mime_type} />
"""
end
defp render_output({:vega_lite_static, spec}, %{id: id}) do
live_component(LivebookWeb.Output.VegaLiteStaticComponent, id: id, spec: spec)
end
defp render_output({:vega_lite_dynamic, pid}, %{id: id, socket: socket}) do
live_render(socket, LivebookWeb.Output.VegaLiteDynamicLive,
id: id,
session: %{"id" => id, "pid" => pid}
)
end
defp render_output({:js, info}, %{id: id, session_id: session_id}) do
live_component(LivebookWeb.Output.JSComponent, id: id, info: info, session_id: session_id)
end
defp render_output({:table_dynamic, pid}, %{id: id, socket: socket}) do
live_render(socket, LivebookWeb.Output.TableDynamicLive,
id: id,
session: %{"id" => id, "pid" => pid}
)
end
defp render_output({:frame_dynamic, pid}, %{
id: id,
socket: socket,
session_id: session_id,
input_values: input_values,
cell_validity_status: cell_validity_status
}) do
live_render(socket, LivebookWeb.Output.FrameDynamicLive,
id: id,
session: %{
"id" => id,
"pid" => pid,
"session_id" => session_id,
"input_values" => input_values,
"cell_validity_status" => cell_validity_status
}
)
live_component(Output.JSComponent, id: id, info: info, session_id: session_id)
end
defp render_output({:frame, outputs, _info}, %{
@ -103,7 +68,7 @@ defmodule LivebookWeb.Output do
input_values: input_values,
session_id: session_id
}) do
live_component(LivebookWeb.Output.FrameComponent,
live_component(Output.FrameComponent,
id: id,
outputs: outputs,
session_id: session_id,
@ -112,19 +77,11 @@ defmodule LivebookWeb.Output do
end
defp render_output({:input, attrs}, %{id: id, input_values: input_values}) do
live_component(LivebookWeb.Output.InputComponent,
id: id,
attrs: attrs,
input_values: input_values
)
live_component(Output.InputComponent, id: id, attrs: attrs, input_values: input_values)
end
defp render_output({:control, attrs}, %{id: id, input_values: input_values}) do
live_component(LivebookWeb.Output.ControlComponent,
id: id,
attrs: attrs,
input_values: input_values
)
live_component(Output.ControlComponent, id: id, attrs: attrs, input_values: input_values)
end
defp render_output({:error, formatted, :runtime_restart_required}, %{
@ -158,6 +115,20 @@ defmodule LivebookWeb.Output do
render_error_message_output(formatted)
end
# TODO: remove on Livebook v0.7
defp render_output(output, %{})
when elem(output, 0) in [
:vega_lite_static,
:vega_lite_dynamic,
:table_dynamic,
:frame_dynamic
] do
render_error_message_output("""
Legacy output format: #{inspect(output)}. Please update Kino to
the latest version.
""")
end
defp render_output(output, %{}) do
render_error_message_output("""
Unknown output format: #{inspect(output)}. If you're using Kino,

View file

@ -1,59 +0,0 @@
defmodule LivebookWeb.Output.FrameDynamicLive do
use LivebookWeb, :live_view
@impl true
def mount(
_params,
%{
"pid" => pid,
"id" => id,
"session_id" => session_id,
"input_values" => input_values,
"cell_validity_status" => cell_validity_status
},
socket
) do
if connected?(socket) do
send(pid, {:connect, self()})
end
{:ok,
assign(socket,
id: id,
output: nil,
session_id: session_id,
input_values: input_values,
cell_validity_status: cell_validity_status
)}
end
@impl true
def render(assigns) do
~H"""
<div>
<%= if @output do %>
<LivebookWeb.Output.outputs
outputs={[{"#{@id}-output", @output}]}
socket={@socket}
session_id={@session_id}
runtime={nil}
input_values={@input_values}
cell_validity_status={@cell_validity_status} />
<% else %>
<div class="text-gray-300">
Empty output frame
</div>
<% end %>
</div>
"""
end
@impl true
def handle_info({:connect_reply, %{output: output}}, socket) do
{:noreply, assign(socket, output: output)}
end
def handle_info({:render, %{output: output}}, socket) do
{:noreply, assign(socket, output: output)}
end
end

View file

@ -1,203 +0,0 @@
defmodule LivebookWeb.Output.TableDynamicLive do
use LivebookWeb, :live_view
@limit 10
@loading_delay_ms 100
@impl true
def mount(_params, %{"pid" => pid, "id" => id}, socket) do
if connected?(socket) do
send(pid, {:connect, self()})
end
{:ok,
assign(socket,
id: id,
pid: pid,
loading: true,
show_loading_timer: nil,
# Data specification
page: 1,
limit: @limit,
order_by: nil,
order: :asc,
# Fetched data
name: "Table",
features: [],
columns: [],
rows: [],
total_rows: 0
)}
end
@impl true
def render(%{loading: true} = assigns) do
~H"""
<div class="max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4">
<div class="h-4 bg-gray-200 rounded-lg w-3/4"></div>
<div class="h-4 bg-gray-200 rounded-lg"></div>
<div class="h-4 bg-gray-200 rounded-lg w-5/6"></div>
</div>
</div>
"""
end
def render(assigns) do
~H"""
<div class="mb-4 flex items-center space-x-3">
<h3 class="font-semibold text-gray-800">
<%= @name %>
</h3>
<div class="grow"></div>
<!-- Actions -->
<div class="flex space-x-2">
<%= if :refetch in @features do %>
<span class="tooltip left" data-tooltip="Refetch">
<button class="icon-button" aria-label="refresh" phx-click="refetch">
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
</span>
<% end %>
</div>
<!-- Pagination -->
<%= if :pagination in @features and @total_rows > 0 do %>
<div class="flex space-x-2">
<button class="flex items-center font-medium text-sm text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:text-gray-300"
phx-click="prev"
disabled={@page == 1}>
<.remix_icon icon="arrow-left-s-line" class="text-xl" />
<span>Prev</span>
</button>
<div class="flex items-center px-3 py-1 rounded-lg border border-gray-300 font-medium text-sm text-gray-400">
<span><%= @page %> of <%= max_page(@total_rows, @limit) %></span>
</div>
<button class="flex items-center font-medium text-sm text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:text-gray-300"
phx-click="next"
disabled={@page == max_page(@total_rows, @limit)}>
<span>Next</span>
<.remix_icon icon="arrow-right-s-line" class="text-xl" />
</button>
</div>
<% end %>
</div>
<%= if @columns == [] do %>
<!-- In case we don't have information about table structure yet -->
<p class="text-gray-700">
No data
</p>
<% else %>
<!-- Data table -->
<div class="shadow-xl-center rounded-lg max-w-full overflow-y-auto tiny-scrollbar">
<table class="w-full">
<thead class="text-left">
<tr class="border-b border-gray-200 whitespace-nowrap">
<%= for {column, idx} <- Enum.with_index(@columns) do %>
<th class={"py-3 px-6 text-gray-700 font-semibold #{if(:sorting in @features, do: "cursor-pointer", else: "pointer-events-none")}"}
phx-click="column_click"
phx-value-column_idx={idx}>
<div class="flex items-center space-x-1">
<span><%= column.label %></span>
<span class={unless(@order_by == column.key, do: "invisible")}>
<.remix_icon icon={order_icon(@order)} class="text-xl align-middle leading-none" />
</span>
</div>
</th>
<% end %>
</tr>
</thead>
<tbody class="text-gray-500">
<%= for row <- @rows do %>
<tr class="border-b border-gray-200 last:border-b-0 hover:bg-gray-50 whitespace-nowrap">
<%= for column <- @columns do %>
<td class="py-3 px-6">
<%= to_string(row.fields[column.key]) %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
"""
end
defp order_icon(:asc), do: "arrow-up-s-line"
defp order_icon(:desc), do: "arrow-down-s-line"
defp max_page(total_rows, limit) do
ceil(total_rows / limit)
end
@impl true
def handle_event("refetch", %{}, socket) do
{:noreply, request_rows(socket)}
end
def handle_event("prev", %{}, socket) do
{:noreply, assign(socket, :page, socket.assigns.page - 1) |> request_rows()}
end
def handle_event("next", %{}, socket) do
{:noreply, assign(socket, :page, socket.assigns.page + 1) |> request_rows()}
end
def handle_event("column_click", %{"column_idx" => idx}, socket) do
idx = String.to_integer(idx)
%{key: key} = Enum.at(socket.assigns.columns, idx)
{order_by, order} =
case {socket.assigns.order_by, socket.assigns.order} do
{^key, :asc} -> {key, :desc}
{^key, :desc} -> {nil, :asc}
_ -> {key, :asc}
end
{:noreply, assign(socket, order_by: order_by, order: order) |> request_rows()}
end
@impl true
def handle_info({:connect_reply, %{name: name, columns: columns, features: features}}, socket) do
{:noreply, assign(socket, name: name, columns: columns, features: features) |> request_rows()}
end
def handle_info({:rows, %{rows: rows, total_rows: total_rows, columns: columns}}, socket) do
columns =
case columns do
:initial -> socket.assigns.columns
columns when is_list(columns) -> columns
end
if socket.assigns.show_loading_timer do
Process.cancel_timer(socket.assigns.show_loading_timer)
end
{:noreply,
assign(socket,
loading: false,
show_loading_timer: nil,
columns: columns,
rows: rows,
total_rows: total_rows
)}
end
def handle_info(:show_loading, socket) do
{:noreply, assign(socket, loading: true, show_loading_timer: nil)}
end
defp request_rows(socket) do
rows_spec = %{
offset: (socket.assigns.page - 1) * socket.assigns.limit,
limit: socket.assigns.limit,
order_by: socket.assigns.order_by,
order: socket.assigns.order
}
send(socket.assigns.pid, {:get_rows, self(), rows_spec})
show_loading_timer = Process.send_after(self(), :show_loading, @loading_delay_ms)
assign(socket, show_loading_timer: show_loading_timer)
end
end

View file

@ -1,34 +0,0 @@
defmodule LivebookWeb.Output.VegaLiteDynamicLive do
use LivebookWeb, :live_view
@impl true
def mount(_params, %{"pid" => pid, "id" => id}, socket) do
if connected?(socket) do
send(pid, {:connect, self()})
end
{:ok, assign(socket, id: id)}
end
@impl true
def render(assigns) do
~H"""
<div id={"vega-lite-#{@id}"} phx-hook="VegaLite" phx-update="ignore" data-id={@id}>
</div>
"""
end
@impl true
def handle_info({:connect_reply, %{spec: spec}}, socket) do
{:noreply, push_event(socket, "vega_lite:#{socket.assigns.id}:init", %{"spec" => spec})}
end
def handle_info({:push, %{data: data, dataset: dataset, window: window}}, socket) do
{:noreply,
push_event(socket, "vega_lite:#{socket.assigns.id}:push", %{
"data" => data,
"dataset" => dataset,
"window" => window
})}
end
end

View file

@ -1,17 +0,0 @@
defmodule LivebookWeb.Output.VegaLiteStaticComponent do
use LivebookWeb, :live_component
@impl true
def update(assigns, socket) do
socket = assign(socket, id: assigns.id)
{:ok, push_event(socket, "vega_lite:#{socket.assigns.id}:init", %{"spec" => assigns.spec})}
end
@impl true
def render(assigns) do
~H"""
<div id={"vega-lite-#{@id}"} phx-hook="VegaLite" phx-update="ignore" data-id={@id}>
</div>
"""
end
end

View file

@ -531,12 +531,7 @@ defmodule Livebook.LiveMarkdown.ExportTest do
IO.puts("hey")\
""",
outputs: [
{0, {:stdout, "hey"}},
{1,
{:vega_lite_static,
%{
"$schema" => "https://vega.github.io/schema/vega-lite/v5.json"
}}}
{0, {:stdout, "hey"}}
]
}
]
@ -657,7 +652,7 @@ defmodule Livebook.LiveMarkdown.ExportTest do
| source: """
IO.puts("hey")\
""",
outputs: [{0, {:table_dynamic, self()}}]
outputs: [{0, {:markdown, "some **Markdown**"}}]
}
]
}

View file

@ -621,107 +621,6 @@ defmodule Livebook.LiveMarkdown.ImportTest do
assert %Notebook{name: "My Notebook", autosave_interval_s: 10} = notebook
end
test "imports notebook with valid vega-lite output" do
markdown = """
# My Notebook
## Section 1
```elixir
Vl.new(width: 500, height: 200)
|> Vl.data_from_series(in: [1, 2, 3, 4, 5], out: [1, 2, 3, 4, 5])
|> Vl.mark(:line)
|> Vl.encode_field(:x, "in", type: :quantitative)
|> Vl.encode_field(:y, "out", type: :quantitative)
```
```vega-lite
{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","data":{"values":[{"in":1,"out":1},{"in":2,"out":2},{"in":3,"out":3},{"in":4,"out":4},{"in":5,"out":5}]},"encoding":{"x":{"field":"in","type":"quantitative"},"y":{"field":"out","type":"quantitative"}},"height":200,"mark":"line","width":500}
```
"""
{notebook, []} = Import.notebook_from_markdown(markdown)
assert %Notebook{
name: "My Notebook",
sections: [
%Notebook.Section{
name: "Section 1",
cells: [
%Cell.Elixir{
source: """
Vl.new(width: 500, height: 200)
|> Vl.data_from_series(in: [1, 2, 3, 4, 5], out: [1, 2, 3, 4, 5])
|> Vl.mark(:line)
|> Vl.encode_field(:x, \"in\", type: :quantitative)
|> Vl.encode_field(:y, \"out\", type: :quantitative)\
""",
outputs: [
{0,
{:vega_lite_static,
%{
"$schema" => "https://vega.github.io/schema/vega-lite/v5.json",
"data" => %{
"values" => [
%{"in" => 1, "out" => 1},
%{"in" => 2, "out" => 2},
%{"in" => 3, "out" => 3},
%{"in" => 4, "out" => 4},
%{"in" => 5, "out" => 5}
]
},
"encoding" => %{
"x" => %{"field" => "in", "type" => "quantitative"},
"y" => %{"field" => "out", "type" => "quantitative"}
},
"height" => 200,
"mark" => "line",
"width" => 500
}}}
]
}
]
}
],
output_counter: 1
} = notebook
end
test "imports notebook with invalid vega-lite output" do
markdown = """
# My Notebook
## Section 1
```elixir
:ok
```
```vega-lite
not_a_json
```
"""
{notebook, []} = Import.notebook_from_markdown(markdown)
assert %Notebook{
name: "My Notebook",
sections: [
%Notebook.Section{
name: "Section 1",
cells: [
%Cell.Elixir{
source: """
:ok\
""",
outputs: []
}
]
}
]
} = notebook
end
describe "backward compatibility" do
test "warns if the imported notebook includes an input" do
markdown = """

View file

@ -277,31 +277,6 @@ defmodule LivebookWeb.SessionLiveTest do
end
describe "outputs" do
test "dynamic frame output renders output sent from the frame server",
%{conn: conn, session: session} do
frame_pid =
spawn(fn ->
output = {:text, "Dynamic output in frame"}
receive do
{:connect, pid} -> send(pid, {:connect_reply, %{output: output}})
end
end)
frame_output = {:frame_dynamic, frame_pid}
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir)
Session.queue_cell_evaluation(session.pid, cell_id)
send(session.pid, {:evaluation_output, cell_id, frame_output})
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
assert render(view) =~ "Dynamic output in frame"
end
test "stdout output update", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir)