feat: more work on backend content and generation
This commit is contained in:
@@ -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
77
lib/bds/generation.ex
Normal 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
|
||||
23
lib/bds/generation/generated_file_hash.ex
Normal file
23
lib/bds/generation/generated_file_hash.ex
Normal 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
200
lib/bds/menu.ex
Normal 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("&", "&")
|
||||
|> 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
|
||||
294
lib/bds/tasks.ex
Normal file
294
lib/bds/tasks.ex
Normal 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
|
||||
47
test/bds/generation_test.exs
Normal file
47
test/bds/generation_test.exs
Normal 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
90
test/bds/menu_test.exs
Normal 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
136
test/bds/tasks_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user