feat: more work on backend content and generation

This commit is contained in:
2026-04-23 18:26:48 +02:00
parent 67615a1afc
commit 52e9c4ba81
8 changed files with 869 additions and 0 deletions

View File

@@ -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

77
lib/bds/generation.ex Normal file
View File

@@ -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

View File

@@ -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

200
lib/bds/menu.ex Normal file
View File

@@ -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(<?xml version="1.0" encoding="UTF-8"?>),
~s(<opml version="2.0">),
~s( <body>),
rendered_items,
~s( </body>),
~s(</opml>),
""
]
|> 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}<outline#{attrs}>",
child_markup,
"#{indent}</outline>"
]
|> Enum.join("\n")
_children ->
"#{indent}<outline#{attrs} />"
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("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace(~s(") , "&quot;")
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

294
lib/bds/tasks.ex Normal file
View File

@@ -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

View File

@@ -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", "<html>hello</html>")
assert first_write.written? == true
output_path = Path.join([temp_dir, "html", "index.html"])
assert File.read!(output_path) == "<html>hello</html>"
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", "<html>hello</html>")
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", "<html>updated</html>")
assert third_write.written? == true
assert third_write.content_hash != first_write.content_hash
assert File.read!(output_path) == "<html>updated</html>"
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", "<html>tag</html>")
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

90
test/bds/menu_test.exs Normal file
View File

@@ -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(<outline kind="home" text="Home")
assert contents =~ ~s(<outline kind="page" text="About" slug="about")
assert contents =~ ~s(<outline kind="submenu" text="Sections")
assert contents =~ ~s(<outline kind="category_archive" text="Notes" slug="notes")
assert {:ok, loaded} = BDS.Menu.get_menu(project.id)
assert loaded == menu
end
test "sync_menu_from_filesystem loads canonical OPML and preserves a prepended Home entry", %{project: project, temp_dir: temp_dir} do
meta_dir = Path.join(temp_dir, "meta")
File.mkdir_p!(meta_dir)
File.write!(
Path.join(meta_dir, "menu.opml"),
[
~s(<?xml version="1.0" encoding="UTF-8"?>),
~s(<opml version="2.0">),
~s( <body>),
~s( <outline kind="page" text="Blog" slug="blog" />),
~s( <outline kind="submenu" text="Topics">),
~s( <outline kind="category_archive" text="Elixir" slug="elixir" />),
~s( </outline>),
~s( </body>),
~s(</opml>)
]
|> 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

136
test/bds/tasks_test.exs Normal file
View File

@@ -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