diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index 615b4b8fe..23891319d 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -105,7 +105,19 @@ const on_jump = (hasBarrier, pluto_actions, cell_id) => () => { * */ export const Cell = ({ cell_input: { cell_id, code, code_folded, metadata }, - cell_result: { queued, running, runtime, errored, output, logs, published_object_keys, depends_on_disabled_cells, depends_on_skipped_cells }, + cell_result: { + queued, + running, + runtime, + errored, + output, + logs, + published_object_keys, + depends_on_disabled_cells, + depends_on_skipped_cells, + stale, + depends_on_stale_cells, + }, cell_dependencies, cell_input_local, notebook_id, @@ -294,8 +306,10 @@ export const Cell = ({ code_folded, skip_as_script, running_disabled, + stale, depends_on_disabled_cells, depends_on_skipped_cells, + depends_on_stale_cells, show_input, shrunk: Object.values(logs).length > 0, hooked_up: output?.has_pluto_hook_features ?? false, @@ -315,9 +329,42 @@ export const Cell = ({ - +
+ + ${stale + ? html`` + : depends_on_stale_cells + ? html`` + : null} +
${code_not_trusted_yet diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index 2afb63b4e..72cfa94f5 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -118,7 +118,7 @@ const first_true_key = (obj) => { * @type {{ * disabled: boolean, * show_logs: boolean, - * skip_as_script: boolean + * skip_as_script: boolean, * }} * * @typedef CellInputData @@ -160,12 +160,14 @@ const first_true_key = (obj) => { * queued: boolean, * running: boolean, * errored: boolean, + * stale: boolean, * runtime: number?, * downstream_cells_map: { string: [string]}, * upstream_cells_map: { string: [string]}, * precedence_heuristic: number?, * depends_on_disabled_cells: boolean, * depends_on_skipped_cells: boolean, + * depends_on_stale_cells: boolean, * output: { * body: string, * persist_js_state: boolean, diff --git a/frontend/editor.css b/frontend/editor.css index dac3fc711..5b601a58b 100644 --- a/frontend/editor.css +++ b/frontend/editor.css @@ -1253,6 +1253,11 @@ pluto-display > div { display: none; } +/* STALE CELLS */ +pluto-cell.stale > pluto-output { + background: var(--pluto-stale-output-bg-color); +} + /* DISABLED CELLS */ pluto-cell.depends_on_disabled_cells > pluto-output, @@ -1390,7 +1395,7 @@ button.floating_back_button, pluto-cell > button, pluto-input > button, pluto-runarea > button, -pluto-shoulder > button, +pluto-shoulder > div > button, nav#slide_controls > button { position: absolute; margin: 0px; @@ -1420,7 +1425,6 @@ pluto-shoulder { --invisible-border: calc(0.5 * var(--pluto-cell-spacing)); --shoulder-width: calc(28px + var(--invisible-border)); --border-radius: calc(5px + var(--invisible-border)); - left: calc(0px - var(--shoulder-width)); width: var(--shoulder-width); border-radius: var(--border-radius) 0px 0px var(--border-radius); @@ -1434,6 +1438,7 @@ pluto-shoulder { bottom: calc(0px - var(--invisible-border)); border: var(--invisible-border) solid rgba(0, 0, 0, 0); border-right: none; + overflow: clip; } pluto-editor.fullscreen pluto-shoulder { --shoulder-width: 2000px; @@ -1444,32 +1449,44 @@ pluto-shoulder:hover { background-clip: padding-box; } -pluto-shoulder > button { +pluto-shoulder > div { flex: 0 0 auto; position: sticky; top: 0; + display: flex; + flex-direction: column; +} + +pluto-shoulder > div > button { + position: static; padding: 4px 5px 4px 10px; } -pluto-cell:focus-within > pluto-shoulder > button { +pluto-cell:focus-within > pluto-shoulder > div > button { /* we use padding instead of 4px extra margin to move the eye to the left so that the hitbox becomes grows - you want to be able to double click the button */ padding-right: 9px; } -/* pluto-cell.code_folded.inline-output > pluto-shoulder > button { +/* pluto-cell.code_folded.inline-output > pluto-shoulder > div > button { margin-top: 3px; } */ -pluto-shoulder > button > span::after { +pluto-shoulder > div > button.foldcode > span::after { background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/eye-outline.svg"); filter: var(--image-filters); } -pluto-cell.code_folded > pluto-shoulder > button > span::after { +pluto-cell.code_folded > pluto-shoulder > div > button.foldcode > span::after { background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/eye-off-outline.svg"); filter: var(--image-filters); } +pluto-shoulder > div > button.stale_cell_marker > span::after, +pluto-shoulder > div > button.depends_on_stale_cells_marker > span::after { + background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/warning-outline.svg"); + filter: var(--image-filters); +} + /* TRAFFIC LIGHT */ pluto-trafficlight { @@ -1548,6 +1565,14 @@ body:not(.___) pluto-cell.errored > pluto-trafficlight { background-clip: padding-box; } +/* stale cells */ +body:not(.___) pluto-cell.stale > pluto-trafficlight, +body:not(.___) pluto-cell.depends_on_stale_cells > pluto-trafficlight { + background: var(--stale-cell-color); + border-left-color: var(--stale-cell-color); + background-clip: padding-box; +} + body:not(.___) pluto-cell.queued > pluto-trafficlight::after { background: repeating-linear-gradient(-45deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 8px, var(--normal-cell-color) 8px, var(--normal-cell-color) 16px); background-clip: padding-box; @@ -1624,7 +1649,7 @@ pluto-input > button > span { pluto-cell > button, pluto-input > button, pluto-runarea > button, - pluto-shoulder > button, + pluto-shoulder > div > button, pluto-cell > pluto-runarea { opacity: 0; /* to make it feel smooth: */ @@ -1638,8 +1663,8 @@ pluto-input > button > span { pluto-cell:hover > pluto-input > button, pluto-cell:focus-within > pluto-input > button, pluto-cell > pluto-runarea > button, - pluto-cell:hover > pluto-shoulder > button, - pluto-cell:focus-within > pluto-shoulder > button { + pluto-cell:hover > pluto-shoulder > div > button, + pluto-cell:focus-within > pluto-shoulder > div > button { opacity: 0.6; transition: opacity 0.25s ease-in-out; } @@ -1650,7 +1675,7 @@ pluto-input > button > span { pluto-cell > button:hover, pluto-cell > pluto-input > button:hover, pluto-cell > pluto-runarea > button:hover, - pluto-cell > pluto-shoulder > button:hover, + pluto-cell > pluto-shoulder > div > button:hover, pluto-cell:hover > pluto-runarea { opacity: 1; /* to make it feel snappy: */ @@ -1661,7 +1686,7 @@ pluto-input > button > span { @media screen and (pointer: coarse) { pluto-cell > button.add_cell, pluto-input > button, - pluto-shoulder > button { + pluto-shoulder > div > button { opacity: 0.25; transition: opacity 0.25s ease-in-out; } @@ -1676,14 +1701,14 @@ pluto-input > button > span { pluto-cell:focus-within > button.add_cell, pluto-cell:focus-within > pluto-input > button, pluto-cell:focus-within > pluto-runarea, - pluto-cell:focus-within > pluto-shoulder > button { + pluto-cell:focus-within > pluto-shoulder > div > button { opacity: 0.6; transition: opacity 0.25s ease-in-out; } pluto-cell > pluto-input > button:focus-within, pluto-cell > button:focus-within, pluto-cell > pluto-input > button:focus-within pluto-cell > pluto-runarea > button:focus-within, - pluto-cell > pluto-shoulder > button:focus-within, + pluto-cell > pluto-shoulder > div > button:focus-within, pluto-cell > pluto-runarea { opacity: 1; /* to make it feel snappy: */ @@ -1694,7 +1719,7 @@ pluto-input > button > span { pluto-cell > button > span::after, pluto-input > button > span::after, pluto-runarea > button > span::after, -pluto-shoulder > button > span::after { +pluto-shoulder > div > button > span::after { display: block; content: " " !important; background-size: 17px 17px; diff --git a/frontend/themes/dark.css b/frontend/themes/dark.css index d72c9ea39..b3b14b309 100644 --- a/frontend/themes/dark.css +++ b/frontend/themes/dark.css @@ -23,6 +23,7 @@ --error-cell-color: rgba(var(--error-color), 0.6); --bright-error-cell-color: rgba(var(--error-color), 0.9); --light-error-cell-color: rgba(var(--error-color), 0); + --stale-cell-color: #b86f00; /*Export styling*/ --export-bg-color: hsl(225deg 17% 18%); @@ -45,6 +46,7 @@ --pluto-output-color: hsl(0deg 0% 77%); --pluto-output-h-color: hsl(0, 0%, 90%); --pluto-output-bg-color: var(--main-bg-color); + --pluto-stale-output-bg-color: #332300; --a-underline: #ffffff69; --blockquote-color: inherit; --blockquote-bg: #2e2e2e; diff --git a/frontend/themes/light.css b/frontend/themes/light.css index c1bd8b4f0..4a4dd51ed 100644 --- a/frontend/themes/light.css +++ b/frontend/themes/light.css @@ -23,6 +23,7 @@ --error-cell-color: rgba(var(--error-color), 0.7); --bright-error-cell-color: rgb(var(--error-color)); --light-error-cell-color: rgba(var(--error-color), 0.05); + --stale-cell-color: #ffba53; /*Export styling*/ --export-bg-color: rgb(60, 67, 101); @@ -45,6 +46,7 @@ --pluto-output-color: hsl(0, 0%, 25%); --pluto-output-h-color: hsl(0, 0%, 12%); --pluto-output-bg-color: white; + --pluto-stale-output-bg-color: #ffeed5; --a-underline: #00000059; --blockquote-color: #555; --blockquote-bg: #f2f2f2; @@ -191,36 +193,36 @@ /* code highlighting */ --cm-color-editor-text: #41323f; - --cm-color-comment: #e96ba8; - --cm-color-atom: #815ba4; - --cm-color-number: #815ba4; - --cm-color-property: #b67a48; - --cm-color-keyword: #ef6155; - --cm-color-string: #da5616; - --cm-color-var: #5668a4; - --cm-color-var2: #37768a; - --cm-color-macro: #5c8c5f; - --cm-color-builtin: #5e7ad3; - --cm-color-function: #cc80ac; - --cm-color-type: hsl(170deg 7% 56%); - --cm-color-bracket: #41323f; - --cm-color-tag: #ef6155; - --cm-color-link: #815ba4; - --cm-color-error-bg: #ef6155; - --cm-color-error: #f7f7f7; + --cm-color-comment: #e96ba8; + --cm-color-atom: #815ba4; + --cm-color-number: #815ba4; + --cm-color-property: #b67a48; + --cm-color-keyword: #ef6155; + --cm-color-string: #da5616; + --cm-color-var: #5668a4; + --cm-color-var2: #37768a; + --cm-color-macro: #5c8c5f; + --cm-color-builtin: #5e7ad3; + --cm-color-function: #cc80ac; + --cm-color-type: hsl(170deg 7% 56%); + --cm-color-bracket: #41323f; + --cm-color-tag: #ef6155; + --cm-color-link: #815ba4; + --cm-color-error-bg: #ef6155; + --cm-color-error: #f7f7f7; --cm-color-matchingBracket: black; --cm-color-matchingBracket-bg: #1b4bbb21; --cm-color-placeholder-text: rgba(0, 0, 0, 0.2); --cm-color-clickable-underline: #ced2ef; /* Mixed parsers */ - --cm-color-html: #48b685; + --cm-color-html: #48b685; --cm-color-html-accent: #00ab85; - --cm-color-css: #876800; - --cm-color-css-accent: #696200; + --cm-color-css: #876800; + --cm-color-css-accent: #696200; --cm-css-why-doesnt-codemirror-highlight-all-the-text-aaa: #3b3700; - --cm-color-md: #005a9b; - --cm-color-md-accent: #00a9d1; + --cm-color-md: #005a9b; + --cm-color-md-accent: #00a9d1; /*autocomplete menu*/ --autocomplete-menu-bg-color: white; diff --git a/src/evaluation/Run.jl b/src/evaluation/Run.jl index ae1d22357..43bc6383d 100644 --- a/src/evaluation/Run.jl +++ b/src/evaluation/Run.jl @@ -75,6 +75,8 @@ function run_reactive_core!( # find (indirectly) skipped-as-script cells and update their status update_skipped_cells_dependency!(notebook, new_topology) + update_stale_cell_dependents!(notebook, new_topology) + removed_cells = setdiff(all_cells(old_topology), all_cells(new_topology)) roots = vcat(roots, removed_cells) @@ -182,22 +184,29 @@ function run_reactive_core!( if (skip = any_interrupted || notebook.wants_to_interrupt || !will_run_code(notebook)) relay_reactivity_error!(cell, InterruptException()) - else - run = run_single!( - (session, notebook), cell, - new_topology.nodes[cell], new_topology.codes[cell]; - user_requested_run = (user_requested_run && cell ∈ roots), - capture_stdout = session.options.evaluation.capture_stdout, - ) - any_interrupted |= run.interrupted - - # Support one bond defining another when setting both simultaneously in PlutoSliderServer - # https://github.com/fonsp/Pluto.jl/issues/1695 - - # set the redefined bound variables to their original value from the request - defs = notebook.topology.nodes[cell].definitions - set_bond_value_pairs!(session, notebook, Iterators.filter(((sym,val),) -> sym ∈ defs, bond_value_pairs)) - end + elseif !(skip = cell.depends_on_stale_cells) + was_stale = cell.stale + + run = run_single!( + (session, notebook), cell, + new_topology.nodes[cell], new_topology.codes[cell]; + user_requested_run=(user_requested_run && cell ∈ roots), + capture_stdout=session.options.evaluation.capture_stdout, + ) + any_interrupted |= run.interrupted + + # Support one bond defining another when setting both simultaneously in PlutoSliderServer + # https://github.com/fonsp/Pluto.jl/issues/1695 + + # set the redefined bound variables to their original value from the request + defs = notebook.topology.nodes[cell].definitions + set_bond_value_pairs!(session, notebook, Iterators.filter(((sym, val),) -> sym ∈ defs, bond_value_pairs)) + + # stale status changed + if was_stale != run.stale + update_stale_cell_dependents!(notebook, new_topology) + end + end cell.running = false Status.report_business_finished!(cell_status, Symbol(i), !skip && !run.errored) @@ -331,7 +340,8 @@ function set_output!(cell::Cell, run, expr_cache::ExprAnalysisCache; persist_js_ end new_published end - + + cell.stale = run.stale cell.runtime = run.runtime cell.errored = run.errored cell.running = cell.queued = false @@ -340,7 +350,8 @@ end function clear_output!(cell::Cell) cell.output = CellOutput() cell.published_objects = Dict{String,Any}() - + + cell.stale = false cell.runtime = nothing cell.errored = false cell.running = cell.queued = false @@ -663,3 +674,38 @@ function update_disabled_cells_dependency!(notebook::Notebook, topology::Noteboo cell.depends_on_disabled_cells = true end end + +""" +Find all runnable cells that depend on stale cells. This does not include the +stale cells themselves unless they themselves depend on stale cells. +""" +function find_stale_cell_dependents(notebook::Notebook, topology::NotebookTopology=notebook.topology)::Set{Cell} + stack = collect(filter(stale, notebook.cells)) + dependents = Set{Cell}() + # depth-first search + while !isempty(stack) + cell = pop!(stack) + + cell_dependents = where_referenced(topology, cell) + + new_dependents = setdiff(cell_dependents, dependents) + union!(dependents, new_dependents) + append!(stack, new_dependents) + end + + dependents +end + +""" +Updates `depends_on_stale_cells` for the cells in the notebook. This does not include the +stale cells themselves unless they themselves depend on stale cells. +""" +function update_stale_cell_dependents!(notebook::Notebook, topology::NotebookTopology=notebook.topology) + for cell in notebook.cells + cell.depends_on_stale_cells = false + end + + for cell in find_stale_cell_dependents(notebook, topology) + cell.depends_on_stale_cells = true + end +end diff --git a/src/notebook/Cell.jl b/src/notebook/Cell.jl index 5af2d4a27..6f263e47f 100644 --- a/src/notebook/Cell.jl +++ b/src/notebook/Cell.jl @@ -43,6 +43,7 @@ Base.@kwdef mutable struct Cell <: PlutoDependencyExplorer.AbstractCell output::CellOutput=CellOutput() queued::Bool=false running::Bool=false + stale::Bool=false published_objects::Dict{String,Any}=Dict{String,Any}() @@ -56,6 +57,7 @@ Base.@kwdef mutable struct Cell <: PlutoDependencyExplorer.AbstractCell depends_on_disabled_cells::Bool=false depends_on_skipped_cells::Bool=false + depends_on_stale_cells::Bool=false metadata::Dict{String,Any}=copy(DEFAULT_CELL_METADATA) end @@ -84,3 +86,4 @@ end can_show_logs(c::Cell) = get(c.metadata, METADATA_SHOW_LOGS_KEY, DEFAULT_CELL_METADATA[METADATA_SHOW_LOGS_KEY]) is_skipped_as_script(c::Cell) = get(c.metadata, METADATA_SKIP_AS_SCRIPT_KEY, DEFAULT_CELL_METADATA[METADATA_SKIP_AS_SCRIPT_KEY]) must_be_commented_in_file(c::Cell) = is_disabled(c) || is_skipped_as_script(c) || c.depends_on_disabled_cells || c.depends_on_skipped_cells +stale(c::Cell) = c.stale diff --git a/src/runner/PlutoRunner/src/PlutoRunner.jl b/src/runner/PlutoRunner/src/PlutoRunner.jl index 4e449833a..f97b28d99 100644 --- a/src/runner/PlutoRunner/src/PlutoRunner.jl +++ b/src/runner/PlutoRunner/src/PlutoRunner.jl @@ -30,6 +30,9 @@ abstract type SpecialPlutoExprValue end struct GiveMeCellID <: SpecialPlutoExprValue end struct GiveMeRerunCellFunction <: SpecialPlutoExprValue end struct GiveMeRegisterCleanupFunction <: SpecialPlutoExprValue end +struct Stale + out +end # TODO: clear key when a cell is deleted furever diff --git a/src/runner/PlutoRunner/src/display/format_output.jl b/src/runner/PlutoRunner/src/display/format_output.jl index 672d5786b..8a62eeb49 100644 --- a/src/runner/PlutoRunner/src/display/format_output.jl +++ b/src/runner/PlutoRunner/src/display/format_output.jl @@ -14,7 +14,8 @@ const table_column_display_limit_increase = 30 const tree_display_extra_items = Dict{UUID,Dict{ObjectDimPair,Int64}}() # This is not a struct to make it easier to pass these objects between processes. -const FormattedCellResult = NamedTuple{(:output_formatted, :errored, :interrupted, :process_exited, :runtime, :published_objects, :has_pluto_hook_features),Tuple{PlutoRunner.MimedOutput,Bool,Bool,Bool,Union{UInt64,Nothing},Dict{String,Any},Bool}} +const FormattedCellResult = NamedTuple{(:output_formatted, :errored, :interrupted, :process_exited, :runtime, :published_objects, :has_pluto_hook_features, :stale),Tuple{PlutoRunner.MimedOutput,Bool,Bool,Bool,Union{UInt64,Nothing},Dict{String,Any},Bool,Bool}} + function formatted_result_of( notebook_id::UUID, @@ -39,6 +40,7 @@ function formatted_result_of( has_pluto_hook_features = haskey(cell_expanded_exprs, cell_id) && cell_expanded_exprs[cell_id].has_pluto_hook_features ans = cell_results[cell_id] errored = ans isa CapturedException + stale = ans isa Stale output_formatted = if (!ends_with_semicolon || errored) with_logger_and_io_to_logs(get_cell_logger(notebook_id, cell_id); capture_stdout) do @@ -70,6 +72,7 @@ function formatted_result_of( runtime = get(cell_runtimes, cell_id, nothing), published_objects, has_pluto_hook_features, + stale, ) end @@ -102,6 +105,7 @@ format_output(@nospecialize(x); context=default_iocontext) = format_output_defau format_output(::Nothing; context=default_iocontext) = ("", MIME"text/plain"()) +format_output((;out)::Stale; context=default_iocontext) = format_output(out; context) function format_output(binding::Base.Docs.Binding; context=default_iocontext) diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index 5960c0ff7..cda79d043 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -124,9 +124,11 @@ function notebook_to_js(notebook::Notebook) "queued" => cell.queued, "running" => cell.running, "errored" => cell.errored, + "stale" => cell.stale, "runtime" => cell.runtime, "logs" => FirebaseyUtils.AppendonlyMarker(cell.logs), "depends_on_skipped_cells" => cell.depends_on_skipped_cells, + "depends_on_stale_cells" => cell.depends_on_stale_cells, ) for (id, cell) in notebook.cells_dict), "cell_order" => notebook.cell_order, diff --git a/test/runtests.jl b/test/runtests.jl index 83b2bae1b..c9066c6bc 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -45,6 +45,7 @@ verify_no_running_processes() @timeit_include("DependencyCache.jl") @timeit_include("Throttled.jl") @timeit_include("cell_disabling.jl") +@timeit_include("stale_cells.jl") verify_no_running_processes() diff --git a/test/stale_cells.jl b/test/stale_cells.jl new file mode 100644 index 000000000..a7731fd18 --- /dev/null +++ b/test/stale_cells.jl @@ -0,0 +1,90 @@ +using Test +using Pluto +using Pluto: update_run!, ServerSession, Cell, Notebook, WorkspaceManager + +@testset "Stale cells" begin + 🍭 = ServerSession() + 🍭.options.evaluation.workspace_use_distributed = false + + notebook = Notebook([ + Cell("a = 1") # 1 + Cell("b = a + 1") # 2 + + Cell(""" + begin + c = b + push!(runs, a + 1) + b + end + """) # 3 + + Cell("a + 1") # 4 + + Cell("begin runs = [] end") # 5 + + Cell("length(runs)") # 6 + + Cell("c") # 7 + ]) + update_run!(🍭, notebook, notebook.cells) + + id(i) = notebook.cells[i].cell_id + c(i) = notebook.cells[i] + get_depends_on_stale_cells(notebook::Notebook) = [i for (i, c) in pairs(notebook.cells) if c.depends_on_stale_cells] + get_stale_cells(notebook::Notebook) = [i for (i, c) in pairs(notebook.cells) if c.stale] + + @test get_stale_cells(notebook) == [] + @test get_depends_on_stale_cells(notebook) == [] + @test all(noerror, notebook.cells) + @test c(6).output.body == "1" + + # Cell metadata gets updated + setcode!(c(2), "begin b = a + 1; Main.PlutoRunner.Stale(Nothing) end") + + update_run!(🍭, notebook, c(2)) + + @test get_stale_cells(notebook) == [2] + @test get_depends_on_stale_cells(notebook) == [3, 7] + @test all(noerror, notebook.cells) + + # depends_on_stale_cells output does not change, but stale cells do + setcode!(c(2), "begin b = a + 2; Main.PlutoRunner.Stale(b) end") + + update_run!(🍭, notebook, c(2)) + update_run!(🍭, notebook, c(6)) + + @test get_stale_cells(notebook) == [2] + @test get_depends_on_stale_cells(notebook) == [3, 7] + @test c(3).output.body == "2" + @test c(2).output.body == "3" + @test all(noerror, notebook.cells) + @test c(6).output.body == "1" + + setcode!(c(1), "a = 2") + + update_run!(🍭, notebook, c(1)) + update_run!(🍭, notebook, c(6)) + + @test get_stale_cells(notebook) == [2] + @test get_depends_on_stale_cells(notebook) == [3, 7] + @test c(3).output.body == "2" + @test c(2).output.body == "4" + @test c(7).output.body == "2" + @test all(noerror, notebook.cells) + @test c(6).output.body == "1" + + # we can resume + setcode!(c(2), "begin b = a + 2; Nothing end") + + update_run!(🍭, notebook, c(2)) + update_run!(🍭, notebook, c(6)) + + @test get_stale_cells(notebook) == [] + @test get_depends_on_stale_cells(notebook) == [] + @test c(3).output.body == "4" + @test c(7).output.body == "4" + @test all(noerror, notebook.cells) + @test c(6).output.body == "2" + + cleanup(🍭, notebook) +end \ No newline at end of file