From 59be6c213e15c9f318526f9c67fedc2b16553e18 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 25 Apr 2026 10:42:07 +0200 Subject: [PATCH] feat: tasks hooked up to UI --- lib/bds/desktop/shell_commands.ex | 321 ++++++++++++++++------- lib/bds/search.ex | 17 +- lib/bds/tasks.ex | 40 ++- priv/ui/app.js | 29 ++ test/bds/desktop/shell_commands_test.exs | 110 +++++++- test/bds/tasks_test.exs | 27 ++ 6 files changed, 437 insertions(+), 107 deletions(-) diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex index 87146df..593827a 100644 --- a/lib/bds/desktop/shell_commands.ex +++ b/lib/bds/desktop/shell_commands.ex @@ -75,12 +75,36 @@ defmodule BDS.Desktop.ShellCommands do end defp dispatch("reindex_text", project, _params) do - queue_task(project, "reindex_text", "Reindex Text", "Search", fn report -> - report.(0.2, "Clearing and rebuilding text indexes") - :ok = Search.reindex_project(project.id) - report.(1.0, "Text indexes rebuilt") - %{project_id: project.id} - end) + group_id = task_group_id("reindex_text") + attrs = %{group_id: group_id, group_name: "Search"} + + {:ok, posts_task} = + Tasks.submit_task("Reindex Search Text", fn report -> + report.(0.0, "Clearing and rebuilding post search indexes") + :ok = Search.reindex_posts(project.id) + report.(1.0, "Post search text reindexed") + %{project_id: project.id, entity: "posts"} + end, attrs) + + {:ok, _media_task} = + Tasks.submit_task("Reindex Media Search Text", fn report -> + report.(0.0, "Clearing and rebuilding media search indexes") + :ok = Search.reindex_media(project.id) + report.(1.0, "Media search text reindexed") + %{project_id: project.id, entity: "media"} + end, attrs) + + {:ok, + %{ + kind: "task_queued", + action: "reindex_text", + title: "Reindex Text", + message: "Search tasks queued", + project_id: project.id, + task_id: posts_task.id, + task_group_id: group_id, + panel_tab: "tasks" + }} end defp dispatch("rebuild_embedding_index", project, _params) do @@ -93,30 +117,63 @@ defmodule BDS.Desktop.ShellCommands do end defp dispatch("rebuild_database", project, _params) do - queue_task(project, "rebuild_database", "Rebuild Database", "Maintenance", fn report -> - report.(0.1, "Rebuilding posts") - {:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post") - report.(0.3, "Rebuilding media") - {:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media") - report.(0.5, "Rebuilding scripts") - {:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script") - report.(0.7, "Rebuilding templates") - {:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template") - report.(0.9, "Rebuilding embeddings") - {:ok, embeddings} = Maintenance.rebuild_from_filesystem(project.id, "embedding") - report.(1.0, "Database rebuild complete") + group_id = task_group_id("rebuild_database") + attrs = %{group_id: group_id, group_name: "Maintenance"} - %{ - project_id: project.id, - counts: %{ - posts: length(posts), - media: length(media), - scripts: length(scripts), - templates: length(templates), - embeddings: length(embeddings) - } - } + {:ok, posts_task} = + Tasks.submit_task("Rebuild Posts From Files", fn report -> + report.(0.0, "Scanning post files") + {:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post") + report.(1.0, "Post rebuild complete") + %{project_id: project.id, counts: %{posts: length(posts)}} + end, attrs) + + {:ok, _media_task} = + Tasks.submit_task("Rebuild Media From Files", fn report -> + report.(0.0, "Scanning media files") + {:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media") + report.(1.0, "Media rebuild complete") + %{project_id: project.id, counts: %{media: length(media)}} + end, attrs) + + {:ok, _scripts_task} = + Tasks.submit_task("Rebuild Scripts From Files", fn report -> + report.(0.0, "Scanning script files") + {:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script") + report.(1.0, "Script rebuild complete") + %{project_id: project.id, counts: %{scripts: length(scripts)}} + end, attrs) + + {:ok, _templates_task} = + Tasks.submit_task("Rebuild Templates From Files", fn report -> + report.(0.0, "Scanning template files") + {:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template") + report.(1.0, "Template rebuild complete") + %{project_id: project.id, counts: %{templates: length(templates)}} + end, attrs) + + Task.start(fn -> + wait_for_group_phase(group_id, [ + "Rebuild Posts From Files", + "Rebuild Media From Files", + "Rebuild Scripts From Files", + "Rebuild Templates From Files" + ]) + + submit_rebuild_followups(project, attrs) end) + + {:ok, + %{ + kind: "task_queued", + action: "rebuild_database", + title: "Rebuild Database", + message: "Maintenance tasks queued", + project_id: project.id, + task_id: posts_task.id, + task_group_id: group_id, + panel_tab: "tasks" + }} end defp dispatch("generate_sitemap", project, _params) do @@ -129,78 +186,39 @@ defmodule BDS.Desktop.ShellCommands do end defp dispatch("validate_site", project, _params) do - with {:ok, report} <- Generation.validate_site(project.id, @site_sections) do - {:ok, - %{ - kind: "open_editor", - action: "validate_site", - project_id: project.id, - route: "site_validation", - title: "Site Validation", - subtitle: "Generated output checked against expected site files", - editorMeta: [ - %{label: "Missing", value: Integer.to_string(length(report.missing_pages))}, - %{label: "Extra", value: Integer.to_string(length(report.extra_pages))}, - %{label: "Stale", value: Integer.to_string(length(report.stale_pages))} - ], - payload: normalize_site_validation(report) - }} - end + queue_task(project, "validate_site", "Validate Site", "Validation", fn report -> + report.(0.2, "Validating generated site output") + {:ok, validation} = Generation.validate_site(project.id, @site_sections) + report.(1.0, "Site validation complete") + site_validation_result(project.id, validation) + end) end defp dispatch("metadata_diff", project, _params) do - with {:ok, report} <- Maintenance.metadata_diff(project.id) do - {:ok, - %{ - kind: "open_editor", - action: "metadata_diff", - project_id: project.id, - route: "metadata_diff", - title: "Metadata Diff", - subtitle: "Database state compared against filesystem metadata", - editorMeta: [ - %{label: "Diffs", value: Integer.to_string(length(report.diff_reports))}, - %{label: "Orphans", value: Integer.to_string(length(report.orphan_reports))} - ], - payload: normalize_metadata_diff(report) - }} - end + queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report -> + report.(0.2, "Comparing database and filesystem metadata") + {:ok, metadata_diff} = Maintenance.metadata_diff(project.id) + report.(1.0, "Metadata diff complete") + metadata_diff_result(project.id, metadata_diff) + end) end defp dispatch("validate_translations", project, _params) do - with {:ok, report} <- Posts.validate_translations(project.id) do - {:ok, - %{ - kind: "open_editor", - action: "validate_translations", - project_id: project.id, - route: "translation_validation", - title: "Translation Validation", - subtitle: "Published posts checked against required blog languages", - editorMeta: [ - %{label: "Missing", value: Integer.to_string(length(report.missing))}, - %{label: "Orphans", value: Integer.to_string(length(report.orphan_files))}, - %{label: "Skipped", value: Integer.to_string(length(report.do_not_translate_posts))} - ], - payload: normalize_translation_validation(report) - }} - end + queue_task(project, "validate_translations", "Validate Translations", "Validation", fn report -> + report.(0.2, "Checking published translations") + {:ok, translation_report} = Posts.validate_translations(project.id) + report.(1.0, "Translation validation complete") + translation_validation_result(project.id, translation_report) + end) end defp dispatch("find_duplicates", project, _params) do - with {:ok, pairs} <- Embeddings.find_duplicates(project.id) do - {:ok, - %{ - kind: "open_editor", - action: "find_duplicates", - project_id: project.id, - route: "find_duplicates", - title: "Find Duplicates", - subtitle: "Potential duplicate posts found via embeddings", - editorMeta: [%{label: "Pairs", value: Integer.to_string(length(pairs))}], - payload: normalize_duplicate_pairs(pairs) - }} - end + queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report -> + report.(0.2, "Checking for duplicate posts") + {:ok, pairs} = Embeddings.find_duplicates(project.id) + report.(1.0, "Duplicate search complete") + duplicate_search_result(project.id, pairs) + end) end defp dispatch("upload_site", project, _params) do @@ -244,6 +262,64 @@ defmodule BDS.Desktop.ShellCommands do }} end + defp submit_rebuild_followups(project, attrs) do + {:ok, _links_task} = + Tasks.submit_task("Rebuild Post Links", fn report -> + report.(0.0, "Rebuilding link graph") + :ok = Posts.rebuild_post_links(project.id) + report.(1.0, "Post links rebuilt") + %{project_id: project.id} + end, attrs) + + {:ok, _thumbs_task} = + Tasks.submit_task("Regenerate Missing Thumbnails", fn report -> + report.(0.0, "Checking missing thumbnails") + result = BDS.Media.regenerate_missing_thumbnails(project.id) + report.(1.0, "Missing thumbnails regenerated") + Map.put(result, :project_id, project.id) + end, attrs) + + {:ok, _embeddings_task} = + Tasks.submit_task("Rebuild Embedding Index", fn report -> + report.(0.0, "Rebuilding semantic index") + {:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id) + report.(1.0, "Embedding index rebuilt") + %{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)} + end, attrs) + + :ok + end + + defp wait_for_group_phase(group_id, names, timeout \\ 30_000) + + defp wait_for_group_phase(_group_id, _names, timeout) when timeout <= 0, do: :timeout + + defp wait_for_group_phase(group_id, names, timeout) do + tasks = + BDS.Tasks.list_tasks() + |> Enum.filter(&(&1.group_id == group_id and &1.name in names)) + + cond do + length(tasks) < length(names) -> + Process.sleep(50) + wait_for_group_phase(group_id, names, timeout - 50) + + Enum.any?(tasks, &(&1.status == :failed)) -> + :failed + + Enum.all?(tasks, &(&1.status == :completed)) -> + :ok + + true -> + Process.sleep(50) + wait_for_group_phase(group_id, names, timeout - 50) + end + end + + defp task_group_id(action) do + action <> "-" <> Integer.to_string(System.unique_integer([:positive, :monotonic])) + end + defp active_project do case Projects.get_active_project() do nil -> {:error, %{message: "No active project selected"}} @@ -276,6 +352,23 @@ defmodule BDS.Desktop.ShellCommands do } end + defp site_validation_result(project_id, report) do + %{ + kind: "open_editor", + action: "validate_site", + project_id: project_id, + route: "site_validation", + title: "Site Validation", + subtitle: "Generated output checked against expected site files", + editorMeta: [ + %{label: "Missing", value: Integer.to_string(length(report.missing_pages))}, + %{label: "Extra", value: Integer.to_string(length(report.extra_pages))}, + %{label: "Stale", value: Integer.to_string(length(report.stale_pages))} + ], + payload: normalize_site_validation(report) + } + end + defp normalize_metadata_diff(report) do %{ summary: %{ @@ -287,6 +380,22 @@ defmodule BDS.Desktop.ShellCommands do } end + defp metadata_diff_result(project_id, report) do + %{ + kind: "open_editor", + action: "metadata_diff", + project_id: project_id, + route: "metadata_diff", + title: "Metadata Diff", + subtitle: "Database state compared against filesystem metadata", + editorMeta: [ + %{label: "Diffs", value: Integer.to_string(length(report.diff_reports))}, + %{label: "Orphans", value: Integer.to_string(length(report.orphan_reports))} + ], + payload: normalize_metadata_diff(report) + } + end + defp normalize_translation_validation(report) do %{ summary: %{ @@ -300,6 +409,23 @@ defmodule BDS.Desktop.ShellCommands do } end + defp translation_validation_result(project_id, report) do + %{ + kind: "open_editor", + action: "validate_translations", + project_id: project_id, + route: "translation_validation", + title: "Translation Validation", + subtitle: "Published posts checked against required blog languages", + editorMeta: [ + %{label: "Missing", value: Integer.to_string(length(report.missing))}, + %{label: "Orphan Files", value: Integer.to_string(length(report.orphan_files))}, + %{label: "Skipped", value: Integer.to_string(length(report.do_not_translate_posts))} + ], + payload: normalize_translation_validation(report) + } + end + defp normalize_duplicate_pairs(pairs) do %{ summary: %{pair_count: length(pairs)}, @@ -307,6 +433,19 @@ defmodule BDS.Desktop.ShellCommands do } end + defp duplicate_search_result(project_id, pairs) do + %{ + kind: "open_editor", + action: "find_duplicates", + project_id: project_id, + route: "find_duplicates", + title: "Find Duplicates", + subtitle: "Potential duplicate posts found via embeddings", + editorMeta: [%{label: "Pairs", value: Integer.to_string(length(pairs))}], + payload: normalize_duplicate_pairs(pairs) + } + end + defp stringify_map(map) when is_map(map) do Map.new(map, fn {key, value} -> {to_string(key), stringify_value(value)} end) end diff --git a/lib/bds/search.ex b/lib/bds/search.ex index 44ddad0..084458d 100644 --- a/lib/bds/search.ex +++ b/lib/bds/search.ex @@ -122,19 +122,30 @@ defmodule BDS.Search do end def reindex_project(project_id) do + :ok = reindex_posts(project_id) + :ok = reindex_media(project_id) + + :ok + end + + def reindex_posts(project_id) do Repo.query!( "DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", [project_id] ) + Repo.all(from post in Post, where: post.project_id == ^project_id) + |> Enum.each(&sync_post/1) + + :ok + end + + def reindex_media(project_id) do Repo.query!( "DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)", [project_id] ) - Repo.all(from post in Post, where: post.project_id == ^project_id) - |> Enum.each(&sync_post/1) - Repo.all(from media in Media, where: media.project_id == ^project_id) |> Enum.each(&sync_media/1) diff --git a/lib/bds/tasks.ex b/lib/bds/tasks.ex index 9154c7b..c4d84ab 100644 --- a/lib/bds/tasks.ex +++ b/lib/bds/tasks.ex @@ -5,6 +5,7 @@ defmodule BDS.Tasks do @default_max_concurrent 3 @default_progress_throttle_ms 250 + @default_recent_finished_limit 10 def start_link(_opts) do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) @@ -35,6 +36,10 @@ defmodule BDS.Tasks do GenServer.call(__MODULE__, :clear_completed) end + def clear_finished do + GenServer.call(__MODULE__, :clear_finished) + end + def cancel_task(task_id) when is_binary(task_id) do GenServer.call(__MODULE__, {:cancel_task, task_id}) end @@ -104,6 +109,15 @@ defmodule BDS.Tasks do {:reply, :ok, %{state | tasks: next_tasks}} end + def handle_call(:clear_finished, _from, state) do + next_tasks = + state.tasks + |> Enum.reject(fn {_task_id, task} -> task.status in [:completed, :failed, :cancelled] end) + |> Map.new() + + {:reply, :ok, %{state | tasks: next_tasks}} + end + def handle_call({:cancel_task, task_id}, _from, state) do cond do Map.has_key?(state.running, task_id) -> @@ -340,14 +354,15 @@ defmodule BDS.Tasks do end defp build_status_snapshot(state) do - tasks = active_tasks(state) + active = active_tasks(state) + tasks = active ++ recent_finished_tasks(state) %{ - active_count: length(tasks), - running_count: Enum.count(tasks, &(&1.status == :running)), - pending_count: Enum.count(tasks, &(&1.status == :pending)), - running_task_message: running_task_message(tasks), - running_task_overflow: running_task_overflow(tasks), + active_count: length(active), + running_count: Enum.count(active, &(&1.status == :running)), + pending_count: Enum.count(active, &(&1.status == :pending)), + running_task_message: running_task_message(active), + running_task_overflow: running_task_overflow(active), tasks: Enum.map(tasks, &public_task/1) } end @@ -359,6 +374,14 @@ defmodule BDS.Tasks do |> Enum.sort_by(&task_sort_key/1) end + defp recent_finished_tasks(state) do + state.tasks + |> Map.values() + |> Enum.filter(&(&1.status in [:completed, :failed, :cancelled])) + |> Enum.sort_by(&DateTime.to_unix(&1.finished_at || &1.created_at, :microsecond), :desc) + |> Enum.take(recent_finished_limit()) + end + defp all_tasks(state) do state.tasks |> Map.values() @@ -406,6 +429,11 @@ defmodule BDS.Tasks do |> Keyword.get(:progress_throttle_ms, @default_progress_throttle_ms) end + defp recent_finished_limit do + Application.get_env(:bds, :tasks, []) + |> Keyword.get(:recent_finished_limit, @default_recent_finished_limit) + end + defp attr(attrs, key) do cond do Map.has_key?(attrs, key) -> Map.get(attrs, key) diff --git a/priv/ui/app.js b/priv/ui/app.js index 7f442bf..03f281a 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -16,6 +16,7 @@ const state = { projects: normalizeProjects(bootstrap.projects), projectMenuOpen: false, taskStatus: normalizeTaskStatus(bootstrap.task_status), + handledTaskResults: {}, outputEntries: [], gitLogEntries: [], uiLanguage: readStoredUiLanguage(bootstrap.i18n?.ui_language || bootstrap.status.right.ui_language), @@ -656,12 +657,40 @@ async function fetchTaskStatus() { state.taskStatus = next; state.status.left.running_task_message = next.running_task_message; state.status.left.running_task_overflow = next.running_task_overflow; + applyCompletedTaskResults(next.tasks); render(); } catch (_error) { // Keep the shell usable if task polling is temporarily unavailable. } } +function applyCompletedTaskResults(tasks) { + pruneHandledTaskResults(tasks); + + tasks.forEach((task) => { + if (task.status !== "completed" || state.handledTaskResults[task.id]) { + return; + } + + if (!task.result || typeof task.result !== "object" || typeof task.result.kind !== "string") { + return; + } + + state.handledTaskResults[task.id] = true; + applyShellCommandResult(task.result); + }); +} + +function pruneHandledTaskResults(tasks) { + const visibleTaskIds = new Set(tasks.map((task) => task.id)); + + Object.keys(state.handledTaskResults).forEach((taskId) => { + if (!visibleTaskIds.has(taskId)) { + delete state.handledTaskResults[taskId]; + } + }); +} + async function fetchProjects() { try { const response = await fetch("/api/projects", { diff --git a/test/bds/desktop/shell_commands_test.exs b/test/bds/desktop/shell_commands_test.exs index a04ad9e..6b5e42c 100644 --- a/test/bds/desktop/shell_commands_test.exs +++ b/test/bds/desktop/shell_commands_test.exs @@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellCommandsTest do Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) :ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview)) :ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Publishing)) + :ok = BDS.Tasks.clear_finished() temp_dir = Path.join(System.tmp_dir!(), "bds-shell-commands-#{System.unique_integer([:positive])}") @@ -17,6 +18,7 @@ defmodule BDS.Desktop.ShellCommandsTest do on_exit(fn -> File.rm_rf(temp_dir) _ = BDS.Preview.stop_preview("default") + _ = BDS.Tasks.clear_finished() end) {:ok, project} = BDS.Projects.create_project(%{name: "Shell Commands", data_path: temp_dir}) @@ -53,11 +55,83 @@ defmodule BDS.Desktop.ShellCommandsTest do assert {:ok, result} = ShellCommands.execute("validate_translations") - assert result.kind == "open_editor" - assert result.route == "translation_validation" - assert result.payload.summary.missing_count == 1 + assert result.kind == "task_queued" + assert result.action == "validate_translations" + assert is_binary(result.task_id) + + completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result))) + + assert completed.group_name == "Validation" + assert completed.result.kind == "open_editor" + assert completed.result.route == "translation_validation" + assert completed.result.payload.summary.missing_count == 1 post_id = post.id - assert [%{"language" => "de", "post_id" => ^post_id}] = result.payload.missing + assert [%{"language" => "de", "post_id" => ^post_id}] = completed.result.payload.missing + end + + test "validate_site queues a tracked validation task and returns the report as an editor payload" do + assert {:ok, result} = ShellCommands.execute("validate_site") + + assert result.kind == "task_queued" + assert result.action == "validate_site" + assert is_binary(result.task_id) + + completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result))) + + assert completed.group_name == "Validation" + assert completed.result.kind == "open_editor" + assert completed.result.route == "site_validation" + assert is_map(completed.result.payload.summary) + end + + test "metadata_diff queues a tracked maintenance task and returns the report as an editor payload" do + assert {:ok, result} = ShellCommands.execute("metadata_diff") + + assert result.kind == "task_queued" + assert result.action == "metadata_diff" + assert is_binary(result.task_id) + + completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result))) + + assert completed.group_name == "Maintenance" + assert completed.result.kind == "open_editor" + assert completed.result.route == "metadata_diff" + assert is_map(completed.result.payload.summary) + end + + test "find_duplicates queues a tracked embeddings task and returns the report as an editor payload" do + assert {:ok, result} = ShellCommands.execute("find_duplicates") + + assert result.kind == "task_queued" + assert result.action == "find_duplicates" + assert is_binary(result.task_id) + + completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result))) + + assert completed.group_name == "Embeddings" + assert completed.result.kind == "open_editor" + assert completed.result.route == "find_duplicates" + assert is_map(completed.result.payload.summary) + end + + test "rebuild_database fans out tracked maintenance tasks for the active project" do + assert {:ok, result} = ShellCommands.execute("rebuild_database") + + assert result.kind == "task_queued" + assert result.action == "rebuild_database" + + tasks = wait_for_tasks_by_name([ + "Rebuild Posts From Files", + "Rebuild Media From Files", + "Rebuild Scripts From Files", + "Rebuild Templates From Files", + "Rebuild Post Links", + "Regenerate Missing Thumbnails", + "Rebuild Embedding Index" + ], &(&1.status == :completed)) + + assert Enum.all?(tasks, &(&1.group_name == "Maintenance")) + assert Enum.all?(tasks, &(&1.status == :completed)) end test "reindex_text queues a tracked background task for the active project", %{project: project} do @@ -67,10 +141,13 @@ defmodule BDS.Desktop.ShellCommandsTest do assert result.action == "reindex_text" assert result.project_id == project.id assert is_binary(result.task_id) + assert is_binary(result.task_group_id) - assert task = BDS.Tasks.get_task(result.task_id) - assert task.group_name == "Search" - assert wait_for_task(result.task_id, &(&1.status in [:completed, :failed])).status == :completed + tasks = wait_for_tasks_by_name(["Reindex Search Text", "Reindex Media Search Text"], &(&1.status == :completed)) + + assert Enum.all?(tasks, &(&1.group_name == "Search")) + assert Enum.all?(tasks, &(&1.group_id == result.task_group_id)) + assert Enum.all?(tasks, &(&1.status == :completed)) end test "missing project schema returns a command error instead of raising" do @@ -96,4 +173,23 @@ defmodule BDS.Desktop.ShellCommandsTest do wait_for_task(task_id, matcher, timeout - 50) end end + + defp wait_for_tasks_by_name(names, matcher), do: wait_for_tasks_by_name(names, matcher, 2_000) + + defp wait_for_tasks_by_name(_names, _matcher, timeout) when timeout <= 0 do + BDS.Tasks.list_tasks() + end + + defp wait_for_tasks_by_name(names, matcher, timeout) do + tasks = BDS.Tasks.list_tasks() + matching_tasks = Enum.filter(tasks, &(&1.name in names)) + + if Enum.all?(names, fn name -> Enum.any?(matching_tasks, &(&1.name == name)) end) and + Enum.all?(matching_tasks, matcher) do + matching_tasks + else + Process.sleep(50) + wait_for_tasks_by_name(names, matcher, timeout - 50) + end + end end diff --git a/test/bds/tasks_test.exs b/test/bds/tasks_test.exs index 1a72865..661b0f3 100644 --- a/test/bds/tasks_test.exs +++ b/test/bds/tasks_test.exs @@ -4,9 +4,11 @@ defmodule BDS.TasksTest do setup do original = Application.get_env(:bds, :tasks, []) Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250) + :ok = BDS.Tasks.clear_finished() on_exit(fn -> Application.put_env(:bds, :tasks, original) + _ = BDS.Tasks.clear_finished() end) :ok @@ -148,6 +150,31 @@ defmodule BDS.TasksTest do assert second_id == second.id end + test "status_snapshot retains recently finished tasks for desktop shell completion state" do + assert {:ok, task} = + BDS.Tasks.submit_task( + "rebuild database", + fn report -> + report.(0.4, "rebuilding") + {:ok, %{counts: %{posts: 2}}} + end, + %{group_id: "maintenance", group_name: "Maintenance"} + ) + + completed = wait_for_task(task.id, &(&1.status == :completed and &1.result == %{counts: %{posts: 2}})) + + snapshot = BDS.Tasks.status_snapshot() + + assert snapshot.active_count == 0 + assert snapshot.running_count == 0 + assert snapshot.pending_count == 0 + assert snapshot.running_task_message == nil + + assert Enum.any?(snapshot.tasks, fn item -> + item.id == completed.id and item.status == :completed and item.result == %{counts: %{posts: 2}} + end) + end + defp receive_started do receive do {:started, name, pid} -> {name, pid}