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