From 52e9c4ba81856230dc5cb7801bd654b9f4c6426b Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 23 Apr 2026 18:26:48 +0200 Subject: [PATCH] feat: more work on backend content and generation --- lib/bds/application.ex | 2 + lib/bds/generation.ex | 77 ++++++ lib/bds/generation/generated_file_hash.ex | 23 ++ lib/bds/menu.ex | 200 +++++++++++++++ lib/bds/tasks.ex | 294 ++++++++++++++++++++++ test/bds/generation_test.exs | 47 ++++ test/bds/menu_test.exs | 90 +++++++ test/bds/tasks_test.exs | 136 ++++++++++ 8 files changed, 869 insertions(+) create mode 100644 lib/bds/generation.ex create mode 100644 lib/bds/generation/generated_file_hash.ex create mode 100644 lib/bds/menu.ex create mode 100644 lib/bds/tasks.ex create mode 100644 test/bds/generation_test.exs create mode 100644 test/bds/menu_test.exs create mode 100644 test/bds/tasks_test.exs diff --git a/lib/bds/application.ex b/lib/bds/application.ex index 69ad8cd..ae5fae0 100644 --- a/lib/bds/application.ex +++ b/lib/bds/application.ex @@ -7,6 +7,8 @@ defmodule BDS.Application do def start(_type, _args) do children = [ BDS.Repo, + BDS.Tasks, + {Task.Supervisor, name: BDS.Tasks.TaskSupervisor}, BDS.Scripting.JobStore, {Task.Supervisor, name: BDS.Scripting.TaskSupervisor}, BDS.Scripting.JobSupervisor diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex new file mode 100644 index 0000000..dc57d19 --- /dev/null +++ b/lib/bds/generation.ex @@ -0,0 +1,77 @@ +defmodule BDS.Generation do + @moduledoc false + + import Ecto.Query + + alias BDS.Generation.GeneratedFileHash + alias BDS.Projects + alias BDS.Repo + + def write_generated_file(project_id, relative_path, content) + when is_binary(project_id) and is_binary(relative_path) and is_binary(content) do + project = Projects.get_project!(project_id) + content_hash = sha256(content) + now = System.system_time(:second) + + case Repo.get_by(GeneratedFileHash, project_id: project_id, relative_path: relative_path) do + %GeneratedFileHash{content_hash: ^content_hash} -> + {:ok, %{relative_path: relative_path, content_hash: content_hash, written?: false}} + + _existing -> + full_path = output_path(project, relative_path) + :ok = File.mkdir_p(Path.dirname(full_path)) + :ok = File.write(full_path, content) + + attrs = %{ + project_id: project_id, + relative_path: relative_path, + content_hash: content_hash, + updated_at: now + } + + %GeneratedFileHash{} + |> GeneratedFileHash.changeset(attrs) + |> Repo.insert!( + on_conflict: [set: [content_hash: content_hash, updated_at: now]], + conflict_target: [:project_id, :relative_path] + ) + + {:ok, %{relative_path: relative_path, content_hash: content_hash, written?: true}} + end + end + + def list_generated_files(project_id) when is_binary(project_id) do + {:ok, + Repo.all( + from generated_file in GeneratedFileHash, + where: generated_file.project_id == ^project_id, + order_by: [asc: generated_file.relative_path] + )} + end + + def delete_generated_file(project_id, relative_path) when is_binary(project_id) and is_binary(relative_path) do + project = Projects.get_project!(project_id) + + case File.rm(output_path(project, relative_path)) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + + Repo.delete_all( + from generated_file in GeneratedFileHash, + where: generated_file.project_id == ^project_id and generated_file.relative_path == ^relative_path + ) + + :ok + end + + defp output_path(project, relative_path) do + Path.join([Projects.project_data_dir(project), "html", relative_path]) + end + + defp sha256(content) do + :crypto.hash(:sha256, content) + |> Base.encode16(case: :lower) + end +end \ No newline at end of file diff --git a/lib/bds/generation/generated_file_hash.ex b/lib/bds/generation/generated_file_hash.ex new file mode 100644 index 0000000..3af2a8a --- /dev/null +++ b/lib/bds/generation/generated_file_hash.ex @@ -0,0 +1,23 @@ +defmodule BDS.Generation.GeneratedFileHash do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + @foreign_key_type :string + + schema "generated_file_hashes" do + field :project_id, :string + field :relative_path, :string + field :content_hash, :string + field :updated_at, :integer + end + + def changeset(record, attrs) do + record + |> cast(attrs, [:project_id, :relative_path, :content_hash, :updated_at], empty_values: [nil]) + |> validate_required([:project_id, :relative_path, :content_hash, :updated_at]) + |> unique_constraint(:relative_path, name: :generated_file_hashes_project_path_idx) + end +end \ No newline at end of file diff --git a/lib/bds/menu.ex b/lib/bds/menu.ex new file mode 100644 index 0000000..549a4fc --- /dev/null +++ b/lib/bds/menu.ex @@ -0,0 +1,200 @@ +defmodule BDS.Menu do + @moduledoc false + + require Record + + alias BDS.Projects + + Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")) + Record.defrecord(:xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")) + + @valid_kinds [:page, :submenu, :category_archive, :home] + + def get_menu(project_id) do + project = Projects.get_project!(project_id) + {:ok, load_menu(project)} + end + + def update_menu(project_id, items) do + project = Projects.get_project!(project_id) + menu = %{items: normalize_menu_items(items)} + :ok = write_menu_file(project, menu) + {:ok, menu} + end + + def sync_menu_from_filesystem(project_id) do + project = Projects.get_project!(project_id) + menu = load_menu(project) + :ok = write_menu_file(project, menu) + {:ok, menu} + end + + defp load_menu(project) do + case File.read(menu_path(project)) do + {:ok, contents} -> + %{items: parse_opml(contents) |> normalize_menu_items()} + + {:error, :enoent} -> + %{items: normalize_menu_items([])} + end + end + + defp write_menu_file(project, menu) do + meta_dir = Path.dirname(menu_path(project)) + :ok = File.mkdir_p(meta_dir) + + path = menu_path(project) + temp_path = path <> ".tmp" + + :ok = File.write(temp_path, serialize_opml(menu.items)) + File.rename(temp_path, path) + end + + defp menu_path(project) do + Path.join([Projects.project_data_dir(project), "meta", "menu.opml"]) + end + + defp normalize_menu_items(items) do + without_home = + items + |> Enum.map(&normalize_menu_item/1) + |> Enum.reject(&(&1.kind == :home)) + + [%{kind: :home, label: "Home", slug: nil} | without_home] + end + + defp normalize_menu_item(item) do + kind = normalize_kind(attr(item, :kind)) + children = attr(item, :children) + + base = %{ + kind: kind, + label: attr(item, :label) || "", + slug: normalize_optional_string(attr(item, :slug)) + } + + if kind == :submenu do + Map.put(base, :children, Enum.map(children || [], &normalize_menu_item/1)) + else + base + end + end + + defp serialize_opml(items) do + rendered_items = + items + |> Enum.map(&render_item(&1, 2)) + |> Enum.join("\n") + + [ + ~s(), + ~s(), + ~s( ), + rendered_items, + ~s( ), + ~s(), + "" + ] + |> Enum.join("\n") + end + + defp render_item(item, level) do + indent = String.duplicate(" ", level) + attrs = render_attributes(item) + + case Map.get(item, :children) do + children when is_list(children) and children != [] -> + child_markup = + children + |> Enum.map(&render_item(&1, level + 1)) + |> Enum.join("\n") + + [ + "#{indent}", + child_markup, + "#{indent}" + ] + |> Enum.join("\n") + + _children -> + "#{indent}" + end + end + + defp render_attributes(item) do + [ + {"kind", item.kind}, + {"text", item.label}, + {"slug", item.slug} + ] + |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) + |> Enum.map_join("", fn {key, value} -> ~s( #{key}="#{xml_escape(to_string(value))}") end) + end + + defp parse_opml(contents) do + {document, _rest} = :xmerl_scan.string(String.to_charlist(contents)) + + :xmerl_xpath.string(~c"/opml/body/outline", document) + |> Enum.map(&parse_outline/1) + end + + defp parse_outline(element) do + kind = element |> xml_attr(:kind) |> normalize_kind() + + base = %{ + kind: kind, + label: xml_attr(element, :text) || "", + slug: normalize_optional_string(xml_attr(element, :slug)) + } + + children = + :xmerl_xpath.string(~c"./outline", element) + |> Enum.map(&parse_outline/1) + + if kind == :submenu do + Map.put(base, :children, children) + else + base + end + end + + defp xml_attr(element, name) do + element + |> xmlElement(:attributes) + |> Enum.find_value(fn attribute -> + if xmlAttribute(attribute, :name) == name do + attribute |> xmlAttribute(:value) |> to_string() + end + end) + end + + defp normalize_kind(kind) when is_atom(kind) and kind in @valid_kinds, do: kind + + defp normalize_kind(kind) when is_binary(kind) do + kind + |> String.to_existing_atom() + |> normalize_kind() + rescue + _error -> :page + end + + defp normalize_optional_string(nil), do: nil + defp normalize_optional_string(""), do: nil + defp normalize_optional_string(value), do: to_string(value) + + defp xml_escape(value) do + value + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace(~s(") , """) + end + + defp attr(attrs, key) do + cond do + Map.has_key?(attrs, key) -> Map.get(attrs, key) + Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) + true -> nil + end + end +end \ No newline at end of file diff --git a/lib/bds/tasks.ex b/lib/bds/tasks.ex new file mode 100644 index 0000000..6db7b2c --- /dev/null +++ b/lib/bds/tasks.ex @@ -0,0 +1,294 @@ +defmodule BDS.Tasks do + @moduledoc false + + use GenServer + + @default_max_concurrent 3 + @default_progress_throttle_ms 250 + + def start_link(_opts) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + def submit_task(name, work, attrs \\ %{}) when is_binary(name) and is_function(work, 1) and is_map(attrs) do + GenServer.call(__MODULE__, {:submit_task, name, work, attrs}) + end + + def get_task(task_id) when is_binary(task_id) do + GenServer.call(__MODULE__, {:get_task, task_id}) + end + + def cancel_task(task_id) when is_binary(task_id) do + GenServer.call(__MODULE__, {:cancel_task, task_id}) + end + + def register_external_task(name, attrs \\ %{}) when is_binary(name) and is_map(attrs) do + GenServer.call(__MODULE__, {:register_external_task, name, attrs}) + end + + def report_progress(task_id, value, message) when is_binary(task_id) do + GenServer.call(__MODULE__, {:report_progress, task_id, value, message}) + end + + def complete_task(task_id) when is_binary(task_id) do + GenServer.call(__MODULE__, {:complete_task, task_id}) + end + + def fail_task(task_id, error_message) when is_binary(task_id) do + GenServer.call(__MODULE__, {:fail_task, task_id, error_message}) + end + + @impl true + def init(_state) do + {:ok, + %{ + tasks: %{}, + queue: [], + running: %{}, + ref_to_task: %{} + }} + end + + @impl true + def handle_call({:submit_task, name, work, attrs}, _from, state) do + task = new_task(name, :pending, attrs) + next_state = put_in(state, [:tasks, task.id], task) + + if map_size(next_state.running) < max_concurrent() do + {:reply, {:ok, public_task(task)}, start_task(next_state, task.id, work)} + else + {:reply, {:ok, public_task(task)}, %{next_state | queue: next_state.queue ++ [{task.id, work}]}} + end + end + + def handle_call({:get_task, task_id}, _from, state) do + {:reply, state.tasks[task_id] && public_task(state.tasks[task_id]), state} + end + + def handle_call({:cancel_task, task_id}, _from, state) do + cond do + Map.has_key?(state.running, task_id) -> + %{pid: pid, ref: ref} = state.running[task_id] + Process.exit(pid, :kill) + + next_state = + state + |> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()}) + |> remove_running(task_id, ref) + |> start_queued_tasks() + + {:reply, :ok, next_state} + + Enum.any?(state.queue, fn {queued_id, _work} -> queued_id == task_id end) -> + next_state = + state + |> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()}) + |> Map.update!(:queue, fn queue -> Enum.reject(queue, fn {queued_id, _work} -> queued_id == task_id end) end) + |> start_queued_tasks() + + {:reply, :ok, next_state} + + state.tasks[task_id] == nil -> + {:reply, {:error, :not_found}, state} + + true -> + {:reply, {:error, :not_running}, state} + end + end + + def handle_call({:register_external_task, name, attrs}, _from, state) do + task = new_task(name, :running, attrs) |> Map.put(:started_at, DateTime.utc_now()) + next_state = put_in(state, [:tasks, task.id], task) + {:reply, {:ok, public_task(task)}, next_state} + end + + def handle_call({:report_progress, task_id, value, message}, _from, state) do + {:reply, :ok, maybe_report_progress(state, task_id, value, message)} + end + + def handle_call({:complete_task, task_id}, _from, state) do + next_state = + state + |> update_task(task_id, %{status: :completed, progress: 1.0, finished_at: DateTime.utc_now()}) + |> start_queued_tasks() + + {:reply, :ok, next_state} + end + + def handle_call({:fail_task, task_id, error_message}, _from, state) do + next_state = + state + |> update_task(task_id, %{status: :failed, message: error_message, finished_at: DateTime.utc_now()}) + |> start_queued_tasks() + + {:reply, :ok, next_state} + end + + @impl true + def handle_info({:task_progress, task_id, value, message}, state) do + {:noreply, maybe_report_progress(state, task_id, value, message)} + end + + def handle_info({ref, result}, state) do + case state.ref_to_task[ref] do + nil -> + {:noreply, state} + + task_id -> + Process.demonitor(ref, [:flush]) + task = state.tasks[task_id] + + next_state = + case task.status do + :cancelled -> + state + + _status -> + attrs = + case normalize_result(result) do + {:ok, value} -> %{status: :completed, result: value, progress: 1.0, finished_at: DateTime.utc_now()} + {:error, reason} -> %{status: :failed, error: reason, finished_at: DateTime.utc_now()} + end + + update_task(state, task_id, attrs) + end + |> remove_running(task_id, ref) + |> start_queued_tasks() + + {:noreply, next_state} + end + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, state) do + case state.ref_to_task[ref] do + nil -> + {:noreply, state} + + task_id -> + task = state.tasks[task_id] + + next_state = + cond do + task.status == :cancelled -> + state + + reason == :normal -> + state + + true -> + update_task(state, task_id, %{status: :failed, error: reason, finished_at: DateTime.utc_now()}) + end + |> remove_running(task_id, ref) + |> start_queued_tasks() + + {:noreply, next_state} + end + end + + defp start_task(state, task_id, work) do + reporter = fn value, message -> send(__MODULE__, {:task_progress, task_id, value, message}) end + + task = + Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> + work.(reporter) + end) + + state + |> update_task(task_id, %{status: :running, started_at: DateTime.utc_now()}) + |> put_in([:running, task_id], %{pid: task.pid, ref: task.ref}) + |> put_in([:ref_to_task, task.ref], task_id) + end + + defp start_queued_tasks(state) do + cond do + map_size(state.running) >= max_concurrent() -> + state + + state.queue == [] -> + state + + true -> + [{task_id, work} | remaining] = state.queue + state + |> Map.put(:queue, remaining) + |> start_task(task_id, work) + |> start_queued_tasks() + end + end + + defp remove_running(state, task_id, ref) do + state + |> update_in([:running], &Map.delete(&1, task_id)) + |> update_in([:ref_to_task], &Map.delete(&1, ref)) + end + + defp maybe_report_progress(state, task_id, value, message) do + case state.tasks[task_id] do + nil -> + state + + task -> + now_ms = System.monotonic_time(:millisecond) + last_reported_at = Map.get(task, :last_reported_at) + + if is_nil(last_reported_at) or now_ms - last_reported_at >= progress_throttle_ms() or value == 1.0 do + update_task(state, task_id, %{progress: value, message: message, last_reported_at: now_ms}) + else + state + end + end + end + + defp new_task(name, status, attrs) do + %{ + id: "task-" <> Integer.to_string(System.unique_integer([:positive, :monotonic])), + name: name, + status: status, + progress: nil, + message: nil, + group_id: attr(attrs, :group_id), + group_name: attr(attrs, :group_name), + created_at: DateTime.utc_now(), + started_at: nil, + finished_at: nil, + result: nil, + error: nil, + last_reported_at: nil + } + end + + defp update_task(state, task_id, attrs) do + update_in(state, [:tasks, task_id], fn + nil -> nil + task -> Map.merge(task, attrs) + end) + end + + defp public_task(nil), do: nil + + defp public_task(task) do + Map.drop(task, [:last_reported_at]) + end + + defp normalize_result({:ok, _value} = result), do: result + defp normalize_result({:error, _reason} = result), do: result + defp normalize_result(value), do: {:ok, value} + + defp max_concurrent do + Application.get_env(:bds, :tasks, []) + |> Keyword.get(:max_concurrent, @default_max_concurrent) + end + + defp progress_throttle_ms do + Application.get_env(:bds, :tasks, []) + |> Keyword.get(:progress_throttle_ms, @default_progress_throttle_ms) + end + + defp attr(attrs, key) do + cond do + Map.has_key?(attrs, key) -> Map.get(attrs, key) + Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) + true -> nil + end + end +end \ No newline at end of file diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs new file mode 100644 index 0000000..7bd2c02 --- /dev/null +++ b/test/bds/generation_test.exs @@ -0,0 +1,47 @@ +defmodule BDS.GenerationTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-generation-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "Generation", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} + end + + test "write_generated_file writes under html output and skips unchanged content by hash", %{project: project, temp_dir: temp_dir} do + assert {:ok, first_write} = BDS.Generation.write_generated_file(project.id, "index.html", "hello") + assert first_write.written? == true + + output_path = Path.join([temp_dir, "html", "index.html"]) + assert File.read!(output_path) == "hello" + + assert {:ok, [tracked_file]} = BDS.Generation.list_generated_files(project.id) + assert tracked_file.relative_path == "index.html" + assert tracked_file.content_hash == first_write.content_hash + + assert {:ok, second_write} = BDS.Generation.write_generated_file(project.id, "index.html", "hello") + assert second_write.written? == false + assert second_write.content_hash == first_write.content_hash + + assert {:ok, third_write} = BDS.Generation.write_generated_file(project.id, "index.html", "updated") + assert third_write.written? == true + assert third_write.content_hash != first_write.content_hash + assert File.read!(output_path) == "updated" + end + + test "delete_generated_file removes tracked output and forgets its hash", %{project: project, temp_dir: temp_dir} do + assert {:ok, _write} = BDS.Generation.write_generated_file(project.id, "tag/elixir/index.html", "tag") + + output_path = Path.join([temp_dir, "html", "tag", "elixir", "index.html"]) + assert File.exists?(output_path) + + assert :ok = BDS.Generation.delete_generated_file(project.id, "tag/elixir/index.html") + refute File.exists?(output_path) + + assert {:ok, files} = BDS.Generation.list_generated_files(project.id) + assert files == [] + end +end \ No newline at end of file diff --git a/test/bds/menu_test.exs b/test/bds/menu_test.exs new file mode 100644 index 0000000..faca2c6 --- /dev/null +++ b/test/bds/menu_test.exs @@ -0,0 +1,90 @@ +defmodule BDS.MenuTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-menu-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "Menu", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} + end + + test "update_menu normalizes Home first, writes meta/menu.opml, and load returns nested items", %{project: project, temp_dir: temp_dir} do + assert {:ok, menu} = + BDS.Menu.update_menu(project.id, [ + %{kind: :page, label: "About", slug: "about"}, + %{ + kind: :submenu, + label: "Sections", + children: [ + %{kind: :category_archive, label: "Notes", slug: "notes"}, + %{kind: :page, label: "Contact", slug: "contact"} + ] + }, + %{kind: :home, label: "Ignored Home"} + ]) + + assert hd(menu.items) == %{kind: :home, label: "Home", slug: nil} + assert Enum.at(menu.items, 1) == %{kind: :page, label: "About", slug: "about"} + + assert Enum.at(menu.items, 2) == %{ + kind: :submenu, + label: "Sections", + slug: nil, + children: [ + %{kind: :category_archive, label: "Notes", slug: "notes"}, + %{kind: :page, label: "Contact", slug: "contact"} + ] + } + + opml_path = Path.join([temp_dir, "meta", "menu.opml"]) + assert File.exists?(opml_path) + + contents = File.read!(opml_path) + assert contents =~ ~s(), + ~s(), + ~s( ), + ~s( ), + ~s( ), + ~s( ), + ~s( ), + ~s( ), + ~s() + ] + |> Enum.join("\n") + ) + + assert {:ok, menu} = BDS.Menu.sync_menu_from_filesystem(project.id) + + assert menu.items == [ + %{kind: :home, label: "Home", slug: nil}, + %{kind: :page, label: "Blog", slug: "blog"}, + %{ + kind: :submenu, + label: "Topics", + slug: nil, + children: [ + %{kind: :category_archive, label: "Elixir", slug: "elixir"} + ] + } + ] + end +end \ No newline at end of file diff --git a/test/bds/tasks_test.exs b/test/bds/tasks_test.exs new file mode 100644 index 0000000..80e27df --- /dev/null +++ b/test/bds/tasks_test.exs @@ -0,0 +1,136 @@ +defmodule BDS.TasksTest do + use ExUnit.Case, async: false + + setup do + original = Application.get_env(:bds, :tasks, []) + Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250) + + on_exit(fn -> + Application.put_env(:bds, :tasks, original) + end) + + :ok + end + + test "submitted tasks respect max concurrency and FIFO queue order" do + runner = self() + + work = fn name -> + fn _report -> + send(runner, {:started, name, self()}) + + receive do + {:release, ^name} -> :ok + end + + {:ok, name} + end + end + + assert {:ok, first} = BDS.Tasks.submit_task("first", work.("first")) + assert {:ok, second} = BDS.Tasks.submit_task("second", work.("second")) + assert {:ok, third} = BDS.Tasks.submit_task("third", work.("third")) + assert {:ok, fourth} = BDS.Tasks.submit_task("fourth", work.("fourth")) + + started = for _ <- 1..3, do: receive_started() + assert Enum.sort(Enum.map(started, &elem(&1, 0))) == ["first", "second", "third"] + + started_by_name = Map.new(started, fn {name, pid} -> {name, pid} end) + + assert BDS.Tasks.get_task(first.id).status == :running + assert BDS.Tasks.get_task(second.id).status == :running + assert BDS.Tasks.get_task(third.id).status == :running + assert BDS.Tasks.get_task(fourth.id).status == :pending + + send(started_by_name["first"], {:release, "first"}) + + assert wait_for_task(first.id, &(&1.status == :completed)).result == "first" + {"fourth", fourth_pid} = receive_started() + assert wait_for_task(fourth.id, &(&1.status == :running)).status == :running + + send(started_by_name["second"], {:release, "second"}) + send(started_by_name["third"], {:release, "third"}) + send(fourth_pid, {:release, "fourth"}) + + assert wait_for_task(second.id, &(&1.status == :completed)).result == "second" + assert wait_for_task(third.id, &(&1.status == :completed)).result == "third" + assert wait_for_task(fourth.id, &(&1.status == :completed)).result == "fourth" + end + + test "cancel_task cancels pending and running tasks" do + runner = self() + + blocking = fn name -> + fn _report -> + send(runner, {:started, name, self()}) + + receive do + {:release, ^name} -> :ok + end + + {:ok, name} + end + end + + assert {:ok, first} = BDS.Tasks.submit_task("one", blocking.("one")) + assert {:ok, second} = BDS.Tasks.submit_task("two", blocking.("two")) + assert {:ok, third} = BDS.Tasks.submit_task("three", blocking.("three")) + assert {:ok, pending} = BDS.Tasks.submit_task("four", blocking.("four")) + + started = for _ <- 1..3, do: receive_started() + started_by_name = Map.new(started, fn {name, pid} -> {name, pid} end) + + assert :ok = BDS.Tasks.cancel_task(pending.id) + assert wait_for_task(pending.id, &(&1.status == :cancelled)).status == :cancelled + + assert :ok = BDS.Tasks.cancel_task(first.id) + assert wait_for_task(first.id, &(&1.status == :cancelled)).status == :cancelled + + send(started_by_name["two"], {:release, "two"}) + send(started_by_name["three"], {:release, "three"}) + + assert wait_for_task(second.id, &(&1.status == :completed)).status == :completed + assert wait_for_task(third.id, &(&1.status == :completed)).status == :completed + end + + test "external tasks are registered as running and can report progress and complete" do + assert {:ok, task} = BDS.Tasks.register_external_task("preview build", %{group_id: "generation", group_name: "Generation"}) + + assert task.status == :running + assert task.group_id == "generation" + assert task.group_name == "Generation" + + assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway") + + progressed = wait_for_task(task.id, &(&1.progress == 0.5 and &1.message == "halfway")) + assert progressed.status == :running + + assert :ok = BDS.Tasks.complete_task(task.id) + assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status == :completed + end + + defp receive_started do + receive do + {:started, name, pid} -> {name, pid} + after + 1_000 -> flunk("task did not start") + end + end + + defp wait_for_task(task_id, predicate, attempts \\ 100) + + defp wait_for_task(task_id, predicate, attempts) when attempts > 0 do + task = BDS.Tasks.get_task(task_id) + + if predicate.(task) do + task + else + Process.sleep(20) + wait_for_task(task_id, predicate, attempts - 1) + end + end + + defp wait_for_task(_task_id, _predicate, 0) do + flunk("task did not reach expected state") + end +end \ No newline at end of file