2283 lines
73 KiB
Elixir
2283 lines
73 KiB
Elixir
defmodule BDS.Desktop.ShellLiveTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
import Phoenix.ConnTest
|
|
import Phoenix.LiveViewTest
|
|
|
|
alias BDS.Persistence
|
|
alias BDS.AI
|
|
alias BDS.Media
|
|
alias BDS.Metadata
|
|
alias BDS.Posts
|
|
alias BDS.Posts.Post
|
|
alias BDS.Projects
|
|
alias BDS.Repo
|
|
alias BDS.Scripts
|
|
alias BDS.Templates
|
|
alias BDS.Tags
|
|
alias BDS.ImportDefinitions
|
|
alias BDS.UI.{Session, Workbench}
|
|
|
|
defmodule FakeEndpointModelHttpClient do
|
|
def get("https://api.example.test/v1/models", _headers) do
|
|
{:ok,
|
|
%{status: 200, headers: %{}, body: Jason.encode!(%{"data" => [%{"id" => "gpt-4.1"}, %{"id" => "gpt-4.1-mini"}]})}}
|
|
end
|
|
|
|
def get("http://localhost:11434/v1/models", _headers) do
|
|
{:ok,
|
|
%{status: 200, headers: %{}, body: Jason.encode!(%{"data" => [%{"id" => "llama3.3"}, %{"id" => "llava:latest"}]})}}
|
|
end
|
|
|
|
def get(_url, _headers), do: {:error, :not_found}
|
|
end
|
|
|
|
defmodule DelayedChatServer do
|
|
use Plug.Router
|
|
import Phoenix.ConnTest, except: [post: 2]
|
|
|
|
plug :match
|
|
plug :dispatch
|
|
|
|
post "/v1/chat/completions" do
|
|
Process.sleep(300)
|
|
|
|
body =
|
|
Jason.encode!(%{
|
|
"choices" => [%{"message" => %{"content" => "Delayed **response**"}}],
|
|
"usage" => %{"prompt_tokens" => 8, "completion_tokens" => 5}
|
|
})
|
|
|
|
conn
|
|
|> Plug.Conn.put_resp_content_type("application/json")
|
|
|> send_resp(200, body)
|
|
end
|
|
|
|
match _ do
|
|
send_resp(conn, 404, "not found")
|
|
end
|
|
end
|
|
|
|
@endpoint BDS.Desktop.Endpoint
|
|
|
|
setup do
|
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
|
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
|
|
|
temp_dir = Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}")
|
|
File.mkdir_p!(temp_dir)
|
|
|
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
|
|
|
{:ok, project} = Projects.create_project(%{name: "Shell Project", data_path: temp_dir})
|
|
{:ok, _project} = Projects.set_active_project(project.id)
|
|
|
|
original_shell_platform = Application.get_env(:bds, :shell_platform)
|
|
original_git_remote_state_provider = Application.get_env(:bds, :git_remote_state_provider)
|
|
original_ai_http_client = Application.get_env(:bds, :ai_http_client)
|
|
|
|
on_exit(fn ->
|
|
if is_nil(original_shell_platform) do
|
|
Application.delete_env(:bds, :shell_platform)
|
|
else
|
|
Application.put_env(:bds, :shell_platform, original_shell_platform)
|
|
end
|
|
|
|
if is_nil(original_git_remote_state_provider) do
|
|
Application.delete_env(:bds, :git_remote_state_provider)
|
|
else
|
|
Application.put_env(:bds, :git_remote_state_provider, original_git_remote_state_provider)
|
|
end
|
|
|
|
if is_nil(original_ai_http_client) do
|
|
Application.delete_env(:bds, :ai_http_client)
|
|
else
|
|
Application.put_env(:bds, :ai_http_client, original_ai_http_client)
|
|
end
|
|
end)
|
|
|
|
%{project: project, temp_dir: temp_dir}
|
|
end
|
|
|
|
test "sidebar headers expose old-app create actions for posts, media, scripts, templates, and imports" do
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ ~s(data-testid="sidebar-create-action")
|
|
assert html =~ ~s(data-sidebar-action="post")
|
|
assert html =~ ~s(data-testid="sidebar-filter-toggle")
|
|
|
|
html = render_click(view, "select_view", %{"view" => "media"})
|
|
|
|
assert html =~ ~s(data-sidebar-action="media")
|
|
assert html =~ ~s(data-testid="sidebar-filter-toggle")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='scripts']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-sidebar-action="script")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='templates']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-sidebar-action="template")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='import']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-sidebar-action="import")
|
|
end
|
|
|
|
test "sidebar create actions follow the old-app post, script, template, and import flows", %{project: project} do
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
post_count_before = Repo.aggregate(Post, :count, :id)
|
|
script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id)
|
|
template_count_before = Repo.aggregate(BDS.Templates.Template, :count, :id)
|
|
import_count_before = Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='post']")
|
|
|> render_click()
|
|
|
|
assert Repo.aggregate(Post, :count, :id) == post_count_before + 1
|
|
|
|
created_post = Repo.one!(Post)
|
|
assert created_post.project_id == project.id
|
|
assert created_post.title == ""
|
|
assert created_post.content == ""
|
|
refute html =~ ~s(data-tab-type="post")
|
|
|
|
_html = render_click(view, "select_view", %{"view" => "scripts"})
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='script']")
|
|
|> render_click()
|
|
|
|
assert Repo.aggregate(BDS.Scripts.Script, :count, :id) == script_count_before + 1
|
|
|
|
created_script = Repo.one!(BDS.Scripts.Script)
|
|
assert created_script.project_id == project.id
|
|
assert created_script.title == "New Script"
|
|
assert created_script.entrypoint == "main"
|
|
assert created_script.content == "print(\"new script\")"
|
|
assert html =~ ~s(data-tab-type="scripts")
|
|
assert html =~ ~s(data-tab-id="#{created_script.id}")
|
|
|
|
_html = render_click(view, "select_view", %{"view" => "templates"})
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='template']")
|
|
|> render_click()
|
|
|
|
assert Repo.aggregate(BDS.Templates.Template, :count, :id) == template_count_before + 1
|
|
|
|
created_template = Repo.get_by!(BDS.Templates.Template, title: "New Template")
|
|
assert created_template.project_id == project.id
|
|
assert created_template.title == "New Template"
|
|
assert created_template.content == ""
|
|
assert html =~ ~s(data-tab-type="templates")
|
|
assert html =~ ~s(data-tab-id="#{created_template.id}")
|
|
|
|
_html = render_click(view, "select_view", %{"view" => "import"})
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='import']")
|
|
|> render_click()
|
|
|
|
assert Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id) == import_count_before + 1
|
|
|
|
created_definition = Repo.one!(ImportDefinitions.ImportDefinition)
|
|
assert created_definition.project_id == project.id
|
|
assert created_definition.name == "New Import Definition"
|
|
assert html =~ ~s(data-tab-type="import")
|
|
assert html =~ ~s(data-tab-id="#{created_definition.id}")
|
|
end
|
|
|
|
test "shell live owns pane visibility and activity selection on the server" do
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ ~s(data-testid="sidebar-shell")
|
|
assert html =~ ~s(data-testid="status-bar")
|
|
assert html =~ ~s(data-testid="status-task-button")
|
|
assert html =~ ~s(class="panel-shell is-hidden")
|
|
assert html =~ ~s(data-testid="activity-button")
|
|
assert html =~ ~s(data-view="posts")
|
|
assert html =~ ~s(data-view="media")
|
|
assert html =~ ~s(aria-label="Posts")
|
|
|
|
html = render_click(view, "select_view", %{"view" => "templates"})
|
|
|
|
assert html =~ ~s(data-view="templates")
|
|
assert html =~ ~s(data-active="true")
|
|
assert html =~ ~s(aria-label="Templates")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='toggle-sidebar']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(class="sidebar-shell is-hidden")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='toggle-sidebar']")
|
|
|> render_click()
|
|
|
|
refute html =~ ~s(class="sidebar-shell is-hidden")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='toggle-panel']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-region="panel")
|
|
refute html =~ ~s(class="panel-shell is-hidden")
|
|
assert html =~ ~s(data-testid="panel-close")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='panel-close']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(class="panel-shell is-hidden")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='media']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(aria-label="Media")
|
|
assert html =~ ~s(data-view="media")
|
|
|
|
settings_html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='settings']")
|
|
|> render_click()
|
|
|
|
assert settings_html =~ ~s(data-testid="sidebar-open-item")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-project']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-tab-type="settings")
|
|
assert html =~ ">Settings<"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='tab-close'][data-tab-type='settings'][data-tab-id='settings']")
|
|
|> render_click()
|
|
|
|
refute html =~ ~s(data-tab-type="settings")
|
|
assert html =~ ~s(class="tab-bar-empty")
|
|
end
|
|
|
|
test "macos hides the custom titlebar and moves shell toggles into the status bar" do
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
refute html =~ ~s(data-testid="window-titlebar")
|
|
refute html =~ ~s(data-testid="window-titlebar-menu-bar")
|
|
refute html =~ ~s(data-testid="window-titlebar-menu-button")
|
|
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
|
assert html =~ ~s(data-testid="status-shell-controls")
|
|
assert html =~ ~s(data-testid="toggle-sidebar")
|
|
assert html =~ ~s(data-testid="toggle-panel")
|
|
assert html =~ ~s(data-testid="toggle-assistant")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='toggle-sidebar']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(class="sidebar-shell is-hidden")
|
|
|
|
html =
|
|
render_hook(view, "native_menu_action", %{"action" => "edit_preferences"})
|
|
|
|
assert html =~ ~s(data-tab-type="settings")
|
|
assert html =~ ">Settings<"
|
|
end
|
|
|
|
test "titlebar menu matches the old shell contract on windows and linux" do
|
|
Application.put_env(:bds, :shell_platform, {:unix, :linux})
|
|
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
refute html =~ ~s(class="window-titlebar is-mac")
|
|
assert html =~ ~s(data-testid="window-titlebar-menu-bar")
|
|
assert html =~ ~s(data-testid="window-titlebar-menu-button")
|
|
assert html =~ ~s(data-menu-group="file")
|
|
assert html =~ ~s(>File<)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='window-titlebar-menu-button'][data-menu-group='file']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
|
assert html =~ ~s(data-testid="window-titlebar-menu-item")
|
|
assert html =~ ~s(data-menu-action="new_post")
|
|
assert html =~ ~s(>New Post<)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='window-titlebar-menu-button'][data-menu-group='edit']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-menu-action="edit_preferences")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='window-titlebar-menu-item'][data-menu-action='edit_preferences']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-tab-type="settings")
|
|
assert html =~ ">Settings<"
|
|
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
|
end
|
|
|
|
test "titlebar menu keyboard navigation is owned by liveview on windows and linux" do
|
|
Application.put_env(:bds, :shell_platform, {:unix, :linux})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='window-titlebar-menu-button'][data-menu-group='file']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-open-menu-group="file")
|
|
|
|
html = render_keydown(view, "titlebar_menu_keydown", %{key: "ArrowRight"})
|
|
|
|
assert html =~ ~s(data-open-menu-group="edit")
|
|
assert html =~ ~s(data-menu-action="edit_preferences")
|
|
|
|
html = render_keydown(view, "titlebar_menu_keydown", %{key: "End"})
|
|
|
|
assert html =~ ~s(class="window-titlebar-menu-item is-keyboard-active")
|
|
assert html =~ ~s(data-menu-action="edit_preferences")
|
|
|
|
html = render_keydown(view, "titlebar_menu_keydown", %{key: "Enter"})
|
|
|
|
assert html =~ ~s(data-tab-type="settings")
|
|
assert html =~ ">Settings<"
|
|
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
|
end
|
|
|
|
test "workbench session restore reopens permanent and transient tabs and selected activity" do
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
session_payload =
|
|
Workbench.new()
|
|
|> Workbench.click_activity(:media)
|
|
|> Workbench.open_tab(:post, "post-1", :pin)
|
|
|> Workbench.open_tab(:media, "media-1", :preview)
|
|
|> Session.serialize()
|
|
|
|
html = render_hook(view, "restore_workbench_session", %{"session" => session_payload})
|
|
|
|
assert html =~ ~s(data-view="media")
|
|
assert html =~ ~s(data-active="true")
|
|
assert html =~ ~s(data-tab-type="post")
|
|
assert html =~ ~s(data-tab-id="post-1")
|
|
assert html =~ ~s(data-tab-type="media")
|
|
assert html =~ ~s(data-tab-id="media-1")
|
|
assert html =~ ~s(class="tab active transient")
|
|
end
|
|
|
|
test "metadata diff refresh reruns after workbench session restore", %{project: project} do
|
|
:ok = BDS.Tasks.clear_finished()
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
session_payload =
|
|
Workbench.new()
|
|
|> Workbench.open_tab(:metadata_diff, "metadata_diff", :pin)
|
|
|> Session.serialize()
|
|
|
|
html = render_hook(view, "restore_workbench_session", %{"session" => session_payload})
|
|
assert html =~ ~s(data-tab-type="metadata_diff")
|
|
|
|
existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id))
|
|
|
|
_html =
|
|
view
|
|
|> element("button[phx-click='rerun_misc_editor']")
|
|
|> render_click()
|
|
|
|
refresh_task = new_task!(existing_ids, "Metadata Diff")
|
|
assert refresh_task.group_name == "Maintenance"
|
|
completed_task!(refresh_task.id)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
assert render(view) =~ project.name
|
|
end
|
|
|
|
test "shell live renders the legacy git activity badge from remote behind count" do
|
|
Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts ->
|
|
{:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}}
|
|
end)
|
|
|
|
{:ok, _view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ ~s(data-view="git")
|
|
assert html =~ ~s(class="activity-bar-badge")
|
|
assert html =~ ">7<"
|
|
end
|
|
|
|
test "assistant sidebar exposes context, prompt, and offline-gated transcript" do
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='toggle-assistant']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-testid="assistant-shell")
|
|
assert html =~ ~s(data-testid="assistant-context")
|
|
assert html =~ ~s(data-testid="assistant-prompt-form")
|
|
assert html =~ ~s(data-testid="assistant-prompt-input")
|
|
assert html =~ ~s(data-testid="assistant-start-button")
|
|
assert html =~ ~s(>Dashboard<)
|
|
|
|
html =
|
|
render_submit(view, "submit_assistant_prompt", %{
|
|
"assistant" => %{"prompt" => "Summarize the current project"}
|
|
})
|
|
|
|
assert html =~ ~s(data-testid="assistant-message-user")
|
|
assert html =~ ~s(data-testid="assistant-message-assistant")
|
|
assert html =~ "Summarize the current project"
|
|
assert html =~ "Automatic AI actions stay gated by airplane mode."
|
|
end
|
|
|
|
test "ai settings expose two openai-compatible endpoints and clear legacy mistral config" do
|
|
assert {:ok, _endpoint} =
|
|
AI.put_endpoint(:mistral, %{
|
|
url: "https://legacy.example.test/v1",
|
|
api_key: "legacy-secret",
|
|
model: "legacy-model"
|
|
})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
_html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='settings']")
|
|
|> render_click()
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-project']")
|
|
|> render_click()
|
|
|
|
assert html =~ "AI"
|
|
assert html =~ "Online Endpoint URL"
|
|
assert html =~ "Offline Endpoint URL"
|
|
assert html =~ "Online API Key"
|
|
assert html =~ "Offline API Key"
|
|
refute html =~ "Mistral API Key"
|
|
refute html =~ "Anthropic / Online API Key"
|
|
|
|
_html =
|
|
render_change(view, "change_settings_ai", %{
|
|
"settings_ai" => %{
|
|
"online_url" => "https://api.example.test/v1",
|
|
"online_api_key" => "online-secret",
|
|
"online_chat_model" => "gpt-4.1",
|
|
"online_title_model" => "gpt-4.1-mini",
|
|
"online_image_analysis_model" => "gpt-4.1-vision",
|
|
"offline_url" => "http://localhost:11434/v1",
|
|
"offline_api_key" => "",
|
|
"offline_chat_model" => "llama3.3",
|
|
"offline_title_model" => "llama3.2",
|
|
"offline_image_analysis_model" => "llava:latest",
|
|
"offline_mode" => "true",
|
|
"system_prompt" => "You are the local test prompt."
|
|
}
|
|
})
|
|
|
|
_html = render_click(view, "save_settings_ai")
|
|
|
|
assert {:ok, online_endpoint} = AI.get_endpoint(:online)
|
|
assert online_endpoint.url == "https://api.example.test/v1"
|
|
assert online_endpoint.api_key == "online-secret"
|
|
assert online_endpoint.model == "gpt-4.1"
|
|
|
|
assert {:ok, offline_endpoint} = AI.get_endpoint(:airplane)
|
|
assert offline_endpoint.url == "http://localhost:11434/v1"
|
|
assert offline_endpoint.api_key in [nil, ""]
|
|
assert offline_endpoint.model == "llama3.3"
|
|
|
|
assert {:ok, nil} = AI.get_endpoint(:mistral)
|
|
assert AI.airplane_mode?()
|
|
assert {:ok, "gpt-4.1"} = AI.get_model_preference(:chat)
|
|
assert {:ok, "gpt-4.1-mini"} = AI.get_model_preference(:title)
|
|
assert {:ok, "gpt-4.1-vision"} = AI.get_model_preference(:image_analysis)
|
|
assert {:ok, "llama3.3"} = AI.get_model_preference(:airplane_chat)
|
|
assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title)
|
|
assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis)
|
|
end
|
|
|
|
test "ai settings refresh models from the configured endpoints" do
|
|
Application.put_env(:bds, :ai_http_client, FakeEndpointModelHttpClient)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
_html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='settings']")
|
|
|> render_click()
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-project']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Refresh Online Models"
|
|
assert html =~ "Refresh Offline Models"
|
|
|
|
_html =
|
|
render_change(view, "change_settings_ai", %{
|
|
"settings_ai" => %{
|
|
"online_url" => "https://api.example.test/v1",
|
|
"offline_url" => "http://localhost:11434/v1"
|
|
}
|
|
})
|
|
|
|
html =
|
|
view
|
|
|> element("button[phx-click='refresh_settings_ai_models'][phx-value-endpoint='online']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(<option value="gpt-4.1"></option>)
|
|
assert html =~ ~s(<option value="gpt-4.1-mini"></option>)
|
|
|
|
html =
|
|
view
|
|
|> element("button[phx-click='refresh_settings_ai_models'][phx-value-endpoint='airplane']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(<option value="llama3.3"></option>)
|
|
assert html =~ ~s(<option value="llava:latest"></option>)
|
|
end
|
|
|
|
test "status bar airplane toggle persists the active ai mode" do
|
|
assert :ok = AI.set_airplane_mode(false)
|
|
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
refute html =~ ~s(status-bar-item offline-badge active)
|
|
refute AI.airplane_mode?()
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='status-offline-button']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(status-bar-item offline-badge active)
|
|
assert AI.airplane_mode?()
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='status-offline-button']")
|
|
|> render_click()
|
|
|
|
refute html =~ ~s(status-bar-item offline-badge active)
|
|
refute AI.airplane_mode?()
|
|
end
|
|
|
|
test "sidebar open supports preview and pin intents for entity tabs" do
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "open_sidebar_item", %{
|
|
"route" => "post",
|
|
"id" => "post-1",
|
|
"title" => "First Post",
|
|
"subtitle" => "draft"
|
|
})
|
|
|
|
assert html =~ ~s(data-tab-type="post")
|
|
assert html =~ ~s(data-tab-id="post-1")
|
|
assert html =~ ~s(class="tab active transient")
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "post",
|
|
"id" => "post-1",
|
|
"title" => "First Post",
|
|
"subtitle" => "draft"
|
|
})
|
|
|
|
assert html =~ ~s(data-tab-id="post-1")
|
|
refute html =~ ~s(class="tab active transient")
|
|
|
|
html =
|
|
render_click(view, "open_sidebar_item", %{
|
|
"route" => "post",
|
|
"id" => "page-1",
|
|
"title" => "About Page",
|
|
"subtitle" => "page"
|
|
})
|
|
|
|
assert html =~ ~s(data-tab-id="post-1")
|
|
assert html =~ ~s(data-tab-id="page-1")
|
|
assert String.contains?(html, ">First Post<")
|
|
assert String.contains?(html, ">About Page<")
|
|
|
|
_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "media",
|
|
"id" => "media-1",
|
|
"title" => "hero.png",
|
|
"subtitle" => "12 KB"
|
|
})
|
|
|
|
html =
|
|
render_click(view, "open_sidebar_item", %{
|
|
"route" => "media",
|
|
"id" => "media-2",
|
|
"title" => "cover.png",
|
|
"subtitle" => "8 KB"
|
|
})
|
|
|
|
assert html =~ ~s(data-tab-id="media-1")
|
|
assert html =~ ~s(data-tab-id="media-2")
|
|
assert String.contains?(html, ">hero.png<")
|
|
assert String.contains?(html, ">cover.png<")
|
|
end
|
|
|
|
test "global shortcuts route through the shared command model" do
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ ~s(data-testid="sidebar-shell")
|
|
assert html =~ ~s(class="panel-shell is-hidden")
|
|
|
|
html = render_keydown(view, "shortcut", %{key: "b", meta: true})
|
|
assert html =~ ~s(class="sidebar-shell is-hidden")
|
|
|
|
html = render_keydown(view, "shortcut", %{key: "j", meta: true})
|
|
refute html =~ ~s(class="panel-shell is-hidden")
|
|
|
|
html = render_keydown(view, "shortcut", %{key: "2", meta: true})
|
|
assert html =~ ~s(data-view="media")
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "media",
|
|
"id" => "media-1",
|
|
"title" => "hero.png",
|
|
"subtitle" => "12 KB"
|
|
})
|
|
|
|
assert html =~ ~s(data-tab-id="media-1")
|
|
|
|
html = render_keydown(view, "shortcut", %{key: "w", meta: true})
|
|
refute html =~ ~s(data-tab-id="media-1")
|
|
end
|
|
|
|
test "hiding the sidebar collapses its width to zero" do
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ ~s(data-testid="sidebar-shell")
|
|
assert html =~ ~s(style="width: 280px;")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='toggle-sidebar']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(class="sidebar-shell is-hidden")
|
|
assert html =~ ~s(style="width: 0px;")
|
|
end
|
|
|
|
test "layout hooks sync persisted widths and apply drag resizing" do
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ ~s(style="width: 280px;")
|
|
|
|
html = render_hook(view, "sync_layout", %{"sidebar_width" => 420, "assistant_sidebar_width" => 480})
|
|
|
|
assert html =~ ~s(data-testid="sidebar-shell")
|
|
assert html =~ ~s(style="width: 420px;")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='toggle-assistant']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-testid="assistant-shell")
|
|
assert html =~ ~s(style="width: 480px;")
|
|
|
|
html = render_hook(view, "resize_panel", %{"target" => "sidebar", "width" => 460})
|
|
|
|
assert html =~ ~s(data-testid="sidebar-shell")
|
|
assert html =~ ~s(style="width: 460px;")
|
|
end
|
|
|
|
test "sidebar filters and load more are server-driven", %{project: project} do
|
|
seed_sidebar_posts(project.id)
|
|
|
|
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "tech", color: "#112233"})
|
|
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ ~s(data-testid="sidebar-search-form")
|
|
assert html =~ ~s(data-testid="sidebar-filter-toggle")
|
|
assert html =~ ~s(class="sidebar-section-header")
|
|
assert html =~ ~s(class="sidebar-actions")
|
|
assert html =~ ~s(data-testid="sidebar-load-more")
|
|
assert html_position(html, ~s(data-testid="sidebar-load-more")) > html_position(html, ">Archived<")
|
|
refute html =~ ~s(data-testid="sidebar-filter-tag")
|
|
assert html =~ "Alpha Post"
|
|
refute html =~ "Overflow Post"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-filter-toggle']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(class="calendar-header collapsible-header collapsed")
|
|
assert html =~ ~s(class="filter-header collapsible-header collapsed")
|
|
refute html =~ ~s(class="calendar-year-header")
|
|
refute html =~ ~s(data-testid="sidebar-filter-tag")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-filter-tags-header']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(class="filter-chip has-color")
|
|
assert html =~ ~s(data-testid="sidebar-filter-tag")
|
|
|
|
html =
|
|
view
|
|
|> form("[data-testid='sidebar-search-form']", %{sidebar_filters: %{search: "Alpha"}})
|
|
|> render_change()
|
|
|
|
assert html =~ "Alpha Post"
|
|
refute html =~ ~s(data-open-title="Beta Post")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-clear-search']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Beta Post"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-filter-tag'][data-filter-tag='tech']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Alpha Post"
|
|
refute html =~ ~s(data-open-title="Beta Post")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-clear-filters']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Beta Post"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-load-more']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Overflow Post"
|
|
end
|
|
|
|
test "project switcher, ui language, dashboard recents, and output log are wired", %{temp_dir: temp_dir} do
|
|
{:ok, other_project} = Projects.create_project(%{name: "Second Blog", data_path: Path.join(temp_dir, "second")})
|
|
{:ok, recent_post} = Posts.create_post(%{project_id: other_project.id, title: "Recent Shell Post", content: "body"})
|
|
|
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert html =~ "Shell Project"
|
|
refute html =~ "Second Blog"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='project-selector-trigger']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-testid="project-dropdown")
|
|
assert html =~ "Second Blog"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='project-item'][data-project-id='#{other_project.id}']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Second Blog"
|
|
|
|
html =
|
|
view
|
|
|> form("[data-testid='status-language-form']", %{ui_language: "de"})
|
|
|> render_change()
|
|
|
|
assert html =~ "Beiträge durchsuchen..."
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='recent-post-item'][data-post-id='#{recent_post.id}']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-tab-type="post")
|
|
assert html =~ ~s(data-tab-id="#{recent_post.id}")
|
|
assert html =~ "Recent Shell Post"
|
|
|
|
html =
|
|
render_click(view, "select_panel_tab", %{"tab" => "output"})
|
|
|
|
assert html =~ "Activated Second Blog"
|
|
end
|
|
|
|
test "task button opens tasks and post panels render real link and git data", %{project: project, temp_dir: temp_dir} do
|
|
{:ok, target} = Posts.create_post(%{project_id: project.id, title: "Target Post", content: "target body"})
|
|
{:ok, target} = Posts.publish_post(target.id)
|
|
target_href = canonical_post_href(target)
|
|
|
|
{:ok, source} =
|
|
Posts.create_post(%{project_id: project.id, title: "Linking Source", content: "See [Target](#{target_href})"})
|
|
|
|
{:ok, source} = Posts.publish_post(source.id)
|
|
:ok = Posts.rebuild_post_links(project.id)
|
|
|
|
init_git_repo!(temp_dir, "Add published posts")
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "post",
|
|
"id" => target.id,
|
|
"title" => "Target Post",
|
|
"subtitle" => "published"
|
|
})
|
|
|
|
assert html =~ "Target Post"
|
|
|
|
html = render_click(view, "select_panel_tab", %{"tab" => "post_links"})
|
|
|
|
assert html =~ "Backlinks"
|
|
assert html =~ source.title
|
|
|
|
html = render_click(view, "select_panel_tab", %{"tab" => "git_log"})
|
|
|
|
assert html =~ "Add published posts"
|
|
|
|
html = render_click(view, "select_panel_tab", %{"tab" => "output"})
|
|
refute html =~ ~s(class="panel-shell is-hidden")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='status-task-button']")
|
|
|> render_click()
|
|
|
|
refute html =~ ~s(class="panel-shell is-hidden")
|
|
assert html =~ ~s(<button class="panel-tab active" type="button" phx-click="select_panel_tab" phx-value-tab="tasks">)
|
|
assert html =~ ~s(class="task-list") or html =~ "No background tasks running"
|
|
end
|
|
|
|
test "metadata diff tasks localize task text, show progress, and open the diff result in the UI" do
|
|
parent = self()
|
|
:ok = BDS.Tasks.clear_finished()
|
|
|
|
{:ok, _task} =
|
|
BDS.Tasks.submit_task(
|
|
"Metadata Diff",
|
|
fn report ->
|
|
send(parent, {:metadata_diff_worker, self()})
|
|
report.(0.35, "Comparing database and filesystem metadata")
|
|
|
|
receive do
|
|
:finish ->
|
|
%{
|
|
kind: "open_editor",
|
|
action: "metadata_diff",
|
|
project_id: "test-project",
|
|
route: "metadata_diff",
|
|
title: "Metadata Diff",
|
|
subtitle: "Database state compared against filesystem metadata",
|
|
editorMeta: [
|
|
%{label: "Diffs", value: "1"},
|
|
%{label: "Orphans", value: "1"}
|
|
],
|
|
payload: %{
|
|
summary: %{diff_count: 3, orphan_count: 2},
|
|
diff_reports: [
|
|
%{
|
|
entity_type: "post",
|
|
entity_id: "post-1",
|
|
label: "Hello DB",
|
|
meta_label: "2026-04-05T12:00:00Z",
|
|
differences: [
|
|
%{field: "slug", db_value: "hello-db", file_value: "hello-file"},
|
|
%{field: "title", db_value: "Hello DB", file_value: "Hello File"}
|
|
]
|
|
},
|
|
%{
|
|
entity_type: "post_translation",
|
|
entity_id: "post-1-de",
|
|
differences: [
|
|
%{field: "excerpt", db_value: "Kurz DB", file_value: "Kurz Datei"}
|
|
]
|
|
},
|
|
%{
|
|
entity_type: "media",
|
|
entity_id: "media-1",
|
|
differences: [
|
|
%{field: "alt", db_value: "Alt DB", file_value: "Alt Datei"}
|
|
]
|
|
}
|
|
],
|
|
orphan_reports: [
|
|
%{path: "posts/2026/04/orphan.md", entity_type: "post"},
|
|
%{path: "media/2026/04/orphan.txt.meta", entity_type: "media"}
|
|
]
|
|
}
|
|
}
|
|
end
|
|
end,
|
|
%{group_name: "Maintenance"}
|
|
)
|
|
|
|
assert_receive {:metadata_diff_worker, worker_pid}
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
_html = render_change(view, "change_ui_language", %{"ui_language" => "de"})
|
|
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='status-task-button']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Metadaten-Diff"
|
|
assert html =~ "Vergleicht Datenbank- und Dateisystem-Metadaten"
|
|
assert html =~ "35%"
|
|
assert html =~ ~s(task-status-running)
|
|
|
|
send(worker_pid, :finish)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html = render(view)
|
|
|
|
assert html =~ ~s(data-tab-type="metadata_diff")
|
|
assert html =~ "Metadaten-Diff"
|
|
assert html =~ ~s(data-testid="metadata-diff-tab")
|
|
assert html =~ ~s(data-entity-tab="posts")
|
|
assert html =~ ~s(data-entity-tab="media")
|
|
assert html =~ ~s(data-testid="metadata-diff-field-pill")
|
|
assert html =~ "slug"
|
|
assert html =~ "title"
|
|
assert html =~ "2026-04-05T12:00:00Z"
|
|
assert html =~ "hello-db"
|
|
assert html =~ "hello-file"
|
|
assert html =~ "posts/2026/04/orphan.md"
|
|
refute html =~ "Beitrag · post-1"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='metadata-diff-tab'][data-entity-tab='media']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Alt DB"
|
|
assert html =~ "Alt Datei"
|
|
refute html =~ "hello-db"
|
|
refute html =~ "posts/2026/04/orphan.md"
|
|
assert html =~ "media/2026/04/orphan.txt.meta"
|
|
|
|
_html =
|
|
view
|
|
|> element("[data-testid='metadata-diff-tab'][data-entity-tab='posts']")
|
|
|> render_click()
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='metadata-diff-field-pill'][data-field='slug']")
|
|
|> render_click()
|
|
|
|
assert html =~ "hello-db"
|
|
assert html =~ "hello-file"
|
|
refute html =~ "Kurz DB"
|
|
refute html =~ "posts/2026/04/orphan.md"
|
|
end
|
|
|
|
test "metadata diff repair actions queue a repair task and refresh the diff result", %{
|
|
project: project,
|
|
temp_dir: temp_dir
|
|
} do
|
|
:ok = BDS.Tasks.clear_finished()
|
|
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Database Post",
|
|
content: "Body",
|
|
excerpt: "Summary",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, published_post} = Posts.publish_post(post.id)
|
|
|
|
post_path = Path.join(temp_dir, published_post.file_path)
|
|
|
|
File.write!(
|
|
post_path,
|
|
[
|
|
"---",
|
|
"id: #{published_post.id}",
|
|
"title: Filesystem Post",
|
|
"slug: #{published_post.slug}",
|
|
"excerpt: Summary",
|
|
"status: published",
|
|
"language: en",
|
|
"createdAt: #{published_post.created_at}",
|
|
"updatedAt: #{published_post.updated_at + 1}",
|
|
"publishedAt: #{published_post.published_at}",
|
|
"tags:",
|
|
"categories:",
|
|
"---",
|
|
"Body",
|
|
""
|
|
]
|
|
|> Enum.join("\n")
|
|
)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("metadata_diff")
|
|
completed_task!(queued.task_id)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html = render(view)
|
|
assert html =~ ~s(data-testid="metadata-diff-repair-button")
|
|
assert html =~ ~s(data-field="title")
|
|
|
|
existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id))
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='metadata-diff-repair-button'][data-direction='file_to_db'][data-field='title']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Repair Metadata Diff"
|
|
|
|
repair_task = new_task!(existing_ids, "Repair Metadata Diff")
|
|
completed_task!(repair_task.id)
|
|
send(view.pid, :refresh_task_status)
|
|
_html = render(view)
|
|
|
|
assert Repo.get!(Post, published_post.id).title == "Filesystem Post"
|
|
|
|
assert {:ok, diff} = BDS.Maintenance.metadata_diff(project.id)
|
|
refute Enum.any?(diff.diff_reports, &(&1.entity_id == published_post.id))
|
|
end
|
|
|
|
test "metadata diff refresh reruns the diff and replaces the current result", %{
|
|
project: project,
|
|
temp_dir: temp_dir
|
|
} do
|
|
:ok = BDS.Tasks.clear_finished()
|
|
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Database Post",
|
|
content: "Body",
|
|
excerpt: "Summary",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, published_post} = Posts.publish_post(post.id)
|
|
post_path = Path.join(temp_dir, published_post.file_path)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("metadata_diff")
|
|
completed_task!(queued.task_id)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html = render(view)
|
|
refute html =~ "Filesystem Post"
|
|
|
|
File.write!(
|
|
post_path,
|
|
[
|
|
"---",
|
|
"id: #{published_post.id}",
|
|
"title: Filesystem Post",
|
|
"slug: #{published_post.slug}",
|
|
"excerpt: Summary",
|
|
"status: published",
|
|
"language: en",
|
|
"createdAt: #{published_post.created_at}",
|
|
"updatedAt: #{published_post.updated_at + 1}",
|
|
"publishedAt: #{published_post.published_at}",
|
|
"tags:",
|
|
"categories:",
|
|
"---",
|
|
"Body",
|
|
""
|
|
]
|
|
|> Enum.join("\n")
|
|
)
|
|
|
|
existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id))
|
|
|
|
html =
|
|
view
|
|
|> element("button[phx-click='rerun_misc_editor']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Metadata Diff"
|
|
|
|
refresh_task = new_task!(existing_ids, "Metadata Diff")
|
|
completed_task!(refresh_task.id)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html = render(view)
|
|
assert html =~ "Filesystem Post"
|
|
end
|
|
|
|
test "metadata diff orphan import action queues an import task and removes the orphan", %{
|
|
project: project,
|
|
temp_dir: temp_dir
|
|
} do
|
|
:ok = BDS.Tasks.clear_finished()
|
|
|
|
orphan_relative_path = Path.join(["posts", "2026", "04", "orphan-post.md"])
|
|
orphan_full_path = Path.join(temp_dir, orphan_relative_path)
|
|
File.mkdir_p!(Path.dirname(orphan_full_path))
|
|
|
|
File.write!(
|
|
orphan_full_path,
|
|
[
|
|
"---",
|
|
"id: orphan-post",
|
|
"title: Orphan Post",
|
|
"slug: orphan-post",
|
|
"status: published",
|
|
"createdAt: 1",
|
|
"updatedAt: 1",
|
|
"publishedAt: 1",
|
|
"tags:",
|
|
"categories:",
|
|
"---",
|
|
"Orphan body",
|
|
""
|
|
]
|
|
|> Enum.join("\n")
|
|
)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("metadata_diff")
|
|
completed_task!(queued.task_id)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html = render(view)
|
|
assert html =~ ~s(data-testid="metadata-diff-import-button")
|
|
assert html =~ orphan_relative_path
|
|
|
|
existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id))
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='metadata-diff-import-button']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Import Metadata Diff Orphans"
|
|
|
|
import_task = new_task!(existing_ids, "Import Metadata Diff Orphans")
|
|
completed_task!(import_task.id)
|
|
send(view.pid, :refresh_task_status)
|
|
_html = render(view)
|
|
|
|
assert Repo.get_by(Post, project_id: project.id, file_path: orphan_relative_path)
|
|
|
|
assert {:ok, diff} = BDS.Maintenance.metadata_diff(project.id)
|
|
refute orphan_relative_path in Enum.map(diff.orphan_reports, & &1.file_path)
|
|
end
|
|
|
|
test "metadata diff embeddings tab exposes repair actions and clears embedding drift", %{project: project} do
|
|
:ok = BDS.Tasks.clear_finished()
|
|
|
|
assert {:ok, _metadata} =
|
|
Metadata.update_project_metadata(project.id, %{semantic_similarity_enabled: true})
|
|
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Embedding Drift",
|
|
content: "space rocket orbit mission galaxy",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, published_post} = Posts.publish_post(post.id)
|
|
assert {:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
|
|
|
|
Repo.delete_all(BDS.Embeddings.Key)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("metadata_diff")
|
|
completed_task!(queued.task_id)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='metadata-diff-tab'][data-entity-tab='embeddings']")
|
|
|> render_click()
|
|
|
|
assert html =~ "content_hash"
|
|
assert html =~ ~s(data-testid="metadata-diff-repair-button")
|
|
|
|
existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id))
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='metadata-diff-repair-button'][data-direction='file_to_db'][data-field='content_hash']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Repair Metadata Diff"
|
|
|
|
repair_task = new_task!(existing_ids, "Repair Metadata Diff")
|
|
completed_task!(repair_task.id)
|
|
send(view.pid, :refresh_task_status)
|
|
_html = render(view)
|
|
|
|
assert Repo.get_by(BDS.Embeddings.Key, project_id: project.id, post_id: published_post.id) != nil
|
|
|
|
assert {:ok, diff} = BDS.Maintenance.metadata_diff(project.id)
|
|
refute Enum.any?(diff.diff_reports, &(&1.entity_type == "embedding" and &1.entity_id == published_post.id))
|
|
end
|
|
|
|
test "post tabs render a real editor and drive save publish discard flows", %{project: project} do
|
|
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "alpha", color: "#112233"})
|
|
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "beta", color: "#445566"})
|
|
assert {:ok, _metadata} = Metadata.add_category(project.id, "notes")
|
|
assert {:ok, _metadata} = Metadata.add_category(project.id, "guides")
|
|
|
|
{:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Draft Shell Post",
|
|
content: "Initial body",
|
|
excerpt: "Initial excerpt",
|
|
tags: ["alpha", "beta"],
|
|
categories: ["notes", "guides"]
|
|
})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "post",
|
|
"id" => post.id,
|
|
"title" => post.title,
|
|
"subtitle" => "draft"
|
|
})
|
|
|
|
assert html =~ ~s(data-testid="post-editor")
|
|
assert html =~ ~s(data-testid="post-editor-form")
|
|
assert html =~ ~s(name="post_editor[title]")
|
|
assert html =~ ~s(name="post_editor[content]")
|
|
assert html =~ ~s(name="post_editor[excerpt]")
|
|
assert html =~ ~s(data-testid="post-publish-button")
|
|
assert html =~ ~s(data-testid="post-discard-button")
|
|
assert html =~ ~s(data-testid="post-detect-language-button")
|
|
assert html =~ "quick-actions-wrapper"
|
|
assert html =~ "quick-actions-btn"
|
|
assert html =~ "editor-header"
|
|
assert html =~ "editor-content"
|
|
assert html =~ "metadata-toggle-header"
|
|
assert html =~ "editor-translations-flags"
|
|
assert html =~ "editor-header-row"
|
|
assert html =~ "editor-media-panel"
|
|
assert html =~ "editor-body"
|
|
assert html =~ "editor-toolbar"
|
|
assert html =~ "editor-footer"
|
|
assert html =~ "tag-input-container"
|
|
assert html =~ "tag-chip"
|
|
assert html =~ "alpha"
|
|
assert html =~ "beta"
|
|
assert html =~ "notes"
|
|
assert html =~ "guides"
|
|
refute html =~ ~s(phx-click="save_post_editor")
|
|
refute html =~ ~s(data-testid="post-delete-button")
|
|
refute html =~ "gallery-button"
|
|
refute html =~ "Desktop workbench content routed through the Elixir shell."
|
|
|
|
html = render_click(view, "toggle_post_editor_quick_actions", %{"id" => post.id})
|
|
|
|
assert html =~ "quick-actions-menu"
|
|
assert html =~ "quick-action-item"
|
|
assert html =~ "quick-actions-divider"
|
|
|
|
html = render_click(view, "set_post_editor_mode", %{"id" => post.id, "mode" => "preview"})
|
|
|
|
assert html =~ ~s(data-testid="post-editor-preview")
|
|
assert html =~ "editor-preview-frame"
|
|
refute html =~ ~s(data-testid="post-editor-content")
|
|
|
|
html = render_click(view, "set_post_editor_mode", %{"id" => post.id, "mode" => "markdown"})
|
|
|
|
assert html =~ ~s(data-testid="post-editor-content")
|
|
assert html =~ ~s(data-monaco-language="markdown-with-macros")
|
|
assert html =~ ~s(phx-hook="MonacoEditor")
|
|
refute html =~ "post-editor-markdown-highlight"
|
|
|
|
html =
|
|
view
|
|
|> form("[data-testid='post-editor-form']", %{
|
|
post_editor: %{
|
|
title: "Updated Shell Post",
|
|
content: "Updated body",
|
|
excerpt: "Updated excerpt",
|
|
tags: "alpha, beta",
|
|
categories: "notes, guides",
|
|
author: "Ada Lovelace",
|
|
language: "de",
|
|
do_not_translate: "false"
|
|
}
|
|
})
|
|
|> render_change()
|
|
|
|
assert html =~ ~s(class="tab active dirty")
|
|
assert html =~ "Updated Shell Post"
|
|
|
|
_html = render_click(view, "save_post_editor", %{"id" => post.id})
|
|
|
|
saved_post = Posts.get_post!(post.id)
|
|
assert saved_post.title == "Updated Shell Post"
|
|
assert saved_post.content == "Updated body"
|
|
assert saved_post.excerpt == "Updated excerpt"
|
|
assert saved_post.tags == ["alpha", "beta"]
|
|
assert saved_post.categories == ["notes", "guides"]
|
|
assert saved_post.author == "Ada Lovelace"
|
|
assert saved_post.language == "de"
|
|
|
|
html = render_click(view, "publish_post_editor", %{"id" => post.id})
|
|
|
|
assert html =~ ~s(data-testid="post-status-badge")
|
|
assert html =~ ~s(data-testid="post-delete-button")
|
|
refute html =~ ~s(data-testid="post-publish-button")
|
|
refute html =~ ~s(data-testid="post-discard-button")
|
|
assert Posts.get_post!(post.id).status == :published
|
|
|
|
_html =
|
|
view
|
|
|> form("[data-testid='post-editor-form']", %{
|
|
post_editor: %{
|
|
title: "Published Shell Post",
|
|
content: "Draft changes after publish",
|
|
excerpt: "Changed after publish",
|
|
tags: "alpha, beta",
|
|
categories: "notes, guides",
|
|
author: "Ada Lovelace",
|
|
language: "de",
|
|
do_not_translate: "false"
|
|
}
|
|
})
|
|
|> render_change()
|
|
|
|
_html = render_click(view, "save_post_editor", %{"id" => post.id})
|
|
assert Posts.get_post!(post.id).status == :draft
|
|
|
|
html = render_click(view, "discard_post_editor", %{"id" => post.id})
|
|
|
|
discarded_post = Posts.get_post!(post.id)
|
|
assert html =~ "Updated Shell Post"
|
|
assert discarded_post.status == :published
|
|
assert discarded_post.content == nil
|
|
assert discarded_post.title == "Updated Shell Post"
|
|
end
|
|
|
|
defp completed_task!(task_id, attempts \\ 50)
|
|
|
|
defp completed_task!(_task_id, 0), do: flunk("task did not complete in time")
|
|
|
|
defp completed_task!(task_id, attempts) do
|
|
case Enum.find(BDS.Tasks.list_tasks(), &(&1.id == task_id and &1.status == :completed)) do
|
|
nil ->
|
|
Process.sleep(20)
|
|
completed_task!(task_id, attempts - 1)
|
|
|
|
task ->
|
|
task
|
|
end
|
|
end
|
|
|
|
defp new_task!(existing_ids, name, attempts \\ 50)
|
|
|
|
defp new_task!(_existing_ids, _name, 0), do: flunk("new task was not created in time")
|
|
|
|
defp new_task!(existing_ids, name, attempts) do
|
|
case Enum.find(BDS.Tasks.list_tasks(), &(&1.name == name and not MapSet.member?(existing_ids, &1.id))) do
|
|
nil ->
|
|
Process.sleep(20)
|
|
new_task!(existing_ids, name, attempts - 1)
|
|
|
|
task ->
|
|
task
|
|
end
|
|
end
|
|
|
|
test "published post editor loads body from file and renders markdown-only editor", %{project: project} do
|
|
{:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Published Editor Post",
|
|
content: "# Heading\n\n```elixir\nIO.puts(:ok)\n```\n",
|
|
excerpt: "Published excerpt"
|
|
})
|
|
|
|
assert {:ok, _published} = Posts.publish_post(post.id)
|
|
published = Posts.get_post!(post.id)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "post",
|
|
"id" => published.id,
|
|
"title" => published.title,
|
|
"subtitle" => "published"
|
|
})
|
|
|
|
assert html =~ ~s(data-testid="post-editor-content")
|
|
assert Regex.match?(~r/name="post_editor\[content\]"[^>]*># Heading\s+```elixir\s+IO\.puts\(:ok\)\s+```/s, html)
|
|
assert html =~ ~s(data-monaco-language="markdown-with-macros")
|
|
assert html =~ ~s(phx-hook="MonacoEditor")
|
|
refute html =~ "post-editor-markdown-highlight"
|
|
refute html =~ ~s(phx-value-mode="visual")
|
|
end
|
|
|
|
test "media tabs render a real editor and drive explicit save flows", %{project: project, temp_dir: temp_dir} do
|
|
{:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Linked Shell Post",
|
|
content: "Body"
|
|
})
|
|
|
|
source_path = Path.join(temp_dir, "cover.txt")
|
|
File.write!(source_path, "media body")
|
|
|
|
assert {:ok, media} =
|
|
Media.import_media(%{
|
|
project_id: project.id,
|
|
source_path: source_path,
|
|
title: "Manual Cover",
|
|
alt: "Cover alt",
|
|
caption: "Cover caption",
|
|
author: "Initial Author",
|
|
language: "en",
|
|
tags: ["cover", "hero"]
|
|
})
|
|
|
|
assert {:ok, _translation} =
|
|
Media.upsert_media_translation(media.id, "de", %{
|
|
title: "Titelbild",
|
|
alt: "Alt DE",
|
|
caption: "Beschriftung DE"
|
|
})
|
|
|
|
assert {:ok, _result} =
|
|
Repo.query(
|
|
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
[Ecto.UUID.generate(), project.id, post.id, media.id, 0, Persistence.now_ms()]
|
|
)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "media",
|
|
"id" => media.id,
|
|
"title" => media.title,
|
|
"subtitle" => media.original_name
|
|
})
|
|
|
|
assert html =~ ~s(data-testid="media-editor")
|
|
assert html =~ ~s(data-testid="media-editor-form")
|
|
assert html =~ ~s(name="media_editor[title]")
|
|
assert html =~ ~s(name="media_editor[alt]")
|
|
assert html =~ ~s(name="media_editor[caption]")
|
|
assert html =~ ~s(name="media_editor[tags]")
|
|
assert html =~ ~s(data-testid="media-save-button")
|
|
assert html =~ ~s(data-testid="media-delete-button")
|
|
assert html =~ "quick-actions-wrapper"
|
|
assert html =~ "media-translations-section"
|
|
assert html =~ "linked-posts-section"
|
|
assert html =~ "Manual Cover"
|
|
assert html =~ "Linked Shell Post"
|
|
assert html =~ "Titelbild"
|
|
refute html =~ "Desktop workbench content routed through the Elixir shell."
|
|
|
|
html = render_click(view, "toggle_media_editor_quick_actions", %{"id" => media.id})
|
|
|
|
assert html =~ "quick-actions-menu"
|
|
assert html =~ "Detect Language"
|
|
assert html =~ "Translate"
|
|
|
|
html =
|
|
view
|
|
|> form("[data-testid='media-editor-form']", %{
|
|
media_editor: %{
|
|
title: "Updated Cover",
|
|
alt: "Updated alt",
|
|
caption: "Updated caption",
|
|
tags: "cover, feature",
|
|
author: "Ada Lovelace",
|
|
language: "fr"
|
|
}
|
|
})
|
|
|> render_change()
|
|
|
|
assert html =~ "Updated Cover"
|
|
|
|
_html = render_click(view, "save_media_editor", %{"id" => media.id})
|
|
|
|
saved_media = Repo.get!(BDS.Media.Media, media.id)
|
|
assert saved_media.title == "Updated Cover"
|
|
assert saved_media.alt == "Updated alt"
|
|
assert saved_media.caption == "Updated caption"
|
|
assert saved_media.tags == ["cover", "feature"]
|
|
assert saved_media.author == "Ada Lovelace"
|
|
assert saved_media.language == "fr"
|
|
end
|
|
|
|
test "media editor follows the old-app translation editing flow", %{project: project, temp_dir: temp_dir} do
|
|
source_path = Path.join(temp_dir, "hero.txt")
|
|
File.write!(source_path, "media body")
|
|
|
|
assert {:ok, media} =
|
|
Media.import_media(%{
|
|
project_id: project.id,
|
|
source_path: source_path,
|
|
title: "Legacy Cover",
|
|
alt: "Legacy alt",
|
|
caption: "Legacy caption",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, _translation} =
|
|
Media.upsert_media_translation(media.id, "de", %{
|
|
title: "Titelbild",
|
|
alt: "Alt DE",
|
|
caption: "Beschriftung DE"
|
|
})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "media",
|
|
"id" => media.id,
|
|
"title" => media.title,
|
|
"subtitle" => media.original_name
|
|
})
|
|
|
|
assert html =~ ~s(class="editor-content media-editor")
|
|
assert html =~ ~s(class="quick-actions-wrapper")
|
|
refute html =~ ~s(class="media-editor-form")
|
|
assert has_element?(view, "[data-testid='media-editor'] .editor-content.media-editor .media-details")
|
|
assert has_element?(view, "[data-testid='media-editor'] .editor-content.media-editor .media-details .media-translations-section")
|
|
assert has_element?(view, "[data-testid='media-editor'] .editor-content.media-editor .media-details .linked-posts-section")
|
|
|
|
html = render_click(view, "edit_media_translation", %{"id" => media.id, "language" => "de"})
|
|
|
|
assert html =~ ~s(class="translation-modal-backdrop")
|
|
assert html =~ ~s(class="translation-modal")
|
|
assert html =~ ~s(name="media_translation[title]")
|
|
assert html =~ ~s(name="media_translation[alt]")
|
|
assert html =~ ~s(name="media_translation[caption]")
|
|
end
|
|
|
|
test "settings and media editors render localized labels when the UI language changes", %{project: project, temp_dir: temp_dir} do
|
|
source_path = Path.join(temp_dir, "localized-hero.txt")
|
|
File.write!(source_path, "media body")
|
|
|
|
assert {:ok, media} =
|
|
Media.import_media(%{
|
|
project_id: project.id,
|
|
source_path: source_path,
|
|
title: "Lokales Bild",
|
|
alt: "Alt",
|
|
caption: "Beschriftung",
|
|
language: "de"
|
|
})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
view
|
|
|> form("[data-testid='status-language-form']", %{ui_language: "de"})
|
|
|> render_change()
|
|
|
|
assert html =~ "Beiträge durchsuchen..."
|
|
|
|
settings_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "settings",
|
|
"id" => "settings-editor",
|
|
"title" => "Editor",
|
|
"subtitle" => "Editor settings"
|
|
})
|
|
|
|
assert settings_html =~ "Standard-Bearbeitungsmodus"
|
|
refute settings_html =~ "Default Editor Mode"
|
|
|
|
media_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "media",
|
|
"id" => media.id,
|
|
"title" => media.title,
|
|
"subtitle" => media.original_name
|
|
})
|
|
|
|
assert media_html =~ "Dateiname"
|
|
assert media_html =~ "Verknüpfte Beiträge"
|
|
refute media_html =~ "File Name"
|
|
refute media_html =~ "Linked Posts"
|
|
end
|
|
|
|
test "remaining step-5 routes render dedicated editors instead of the generic shell placeholder", %{project: project} do
|
|
assert {:ok, script} =
|
|
Scripts.create_script(%{
|
|
project_id: project.id,
|
|
title: "Sync Script",
|
|
kind: :utility,
|
|
content: "def main():\n return 'ok'\n"
|
|
})
|
|
|
|
assert {:ok, template} =
|
|
Templates.create_template(%{
|
|
project_id: project.id,
|
|
title: "Post Template",
|
|
kind: :post,
|
|
content: "<article>{{ post.title }}</article>"
|
|
})
|
|
|
|
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "feature"})
|
|
assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat"})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
settings_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "settings",
|
|
"id" => "settings",
|
|
"title" => "Settings",
|
|
"subtitle" => "Project settings"
|
|
})
|
|
|
|
assert settings_html =~ ~s(class="settings-view-shell")
|
|
assert settings_html =~ ~s(class="setting-section")
|
|
refute settings_html =~ "Desktop workbench content routed through the Elixir shell."
|
|
|
|
tags_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "tags",
|
|
"id" => "tags",
|
|
"title" => "Tags",
|
|
"subtitle" => "Manage tags"
|
|
})
|
|
|
|
assert tags_html =~ ~s(class="tags-view-shell")
|
|
assert tags_html =~ ~s(class="tags-section")
|
|
refute tags_html =~ "Desktop workbench content routed through the Elixir shell."
|
|
|
|
style_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "style",
|
|
"id" => "style",
|
|
"title" => "Style",
|
|
"subtitle" => "Theme preview"
|
|
})
|
|
|
|
assert style_html =~ ~s(class="style-view")
|
|
assert style_html =~ ~s(class="style-theme-picker")
|
|
refute style_html =~ "Desktop workbench content routed through the Elixir shell."
|
|
|
|
script_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "scripts",
|
|
"id" => script.id,
|
|
"title" => script.title,
|
|
"subtitle" => script.slug
|
|
})
|
|
|
|
assert script_html =~ ~s(class="scripts-view-shell")
|
|
assert script_html =~ "scripts-monaco"
|
|
assert script_html =~ ~s(data-monaco-language="lua")
|
|
assert script_html =~ ~s(data-monaco-word-wrap="on")
|
|
assert script_html =~ ~s(phx-hook="MonacoEditor")
|
|
refute script_html =~ "Desktop workbench content routed through the Elixir shell."
|
|
|
|
template_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "templates",
|
|
"id" => template.id,
|
|
"title" => template.title,
|
|
"subtitle" => template.slug
|
|
})
|
|
|
|
assert template_html =~ ~s(class="templates-view-shell")
|
|
assert template_html =~ "templates-monaco"
|
|
assert template_html =~ ~s(data-monaco-language="liquid")
|
|
assert template_html =~ ~s(data-monaco-word-wrap="on")
|
|
assert template_html =~ ~s(phx-hook="MonacoEditor")
|
|
refute template_html =~ "Desktop workbench content routed through the Elixir shell."
|
|
|
|
chat_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "chat",
|
|
"id" => conversation.id,
|
|
"title" => conversation.title,
|
|
"subtitle" => conversation.model || "chat"
|
|
})
|
|
|
|
assert chat_html =~ ~s(class="chat-panel")
|
|
assert chat_html =~ ~s(class="chat-input-container")
|
|
refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
|
|
end
|
|
|
|
test "chat editor renders legacy model controls, tool markers, and structured tool surfaces" do
|
|
assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"})
|
|
|
|
now = Persistence.now_ms()
|
|
|
|
Repo.insert!(
|
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
|
conversation_id: conversation.id,
|
|
role: :user,
|
|
content: "Show me a table",
|
|
created_at: now
|
|
})
|
|
)
|
|
|
|
Repo.insert!(
|
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
|
conversation_id: conversation.id,
|
|
role: :assistant,
|
|
content: "Here is the current summary.",
|
|
tool_calls:
|
|
Jason.encode!([
|
|
%{
|
|
"id" => "call-table",
|
|
"name" => "render_table",
|
|
"arguments" => %{"title" => "Blog Stats", "columns" => ["Metric", "Value"]}
|
|
}
|
|
]),
|
|
created_at: now + 1
|
|
})
|
|
)
|
|
|
|
Repo.insert!(
|
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
|
conversation_id: conversation.id,
|
|
role: :tool,
|
|
tool_call_id: "call-table",
|
|
content:
|
|
Jason.encode!(%{
|
|
"type" => "table",
|
|
"title" => "Blog Stats",
|
|
"columns" => ["Metric", "Value"],
|
|
"rows" => [["Posts", "1"], ["Media", "0"]]
|
|
}),
|
|
created_at: now + 2
|
|
})
|
|
)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "chat",
|
|
"id" => conversation.id,
|
|
"title" => conversation.title,
|
|
"subtitle" => conversation.model || "chat"
|
|
})
|
|
|
|
assert html =~ ~s(data-testid="chat-model-selector-button")
|
|
assert html =~ "gpt-4.1"
|
|
assert html =~ ~s(data-testid="chat-tool-marker")
|
|
assert html =~ "render_table"
|
|
assert html =~ ~s(data-testid="chat-tool-surface")
|
|
assert html =~ "Blog Stats"
|
|
assert html =~ "Metric"
|
|
assert html =~ "Posts"
|
|
end
|
|
|
|
test "chat editor groups selector models by provider and uses catalog labels" do
|
|
updated_at = Persistence.now_ms()
|
|
|
|
Repo.insert!(
|
|
BDS.AI.CatalogProvider.changeset(%BDS.AI.CatalogProvider{}, %{
|
|
id: "openai",
|
|
name: "OpenAI",
|
|
updated_at: updated_at
|
|
})
|
|
)
|
|
|
|
Repo.insert!(
|
|
BDS.AI.CatalogProvider.changeset(%BDS.AI.CatalogProvider{}, %{
|
|
id: "ollama",
|
|
name: "Ollama",
|
|
updated_at: updated_at
|
|
})
|
|
)
|
|
|
|
Repo.insert!(
|
|
BDS.AI.Model.changeset(%BDS.AI.Model{}, %{
|
|
provider: "openai",
|
|
model_id: "gpt-4o",
|
|
name: "GPT-4o",
|
|
context_window: 128_000,
|
|
max_input_tokens: 128_000,
|
|
max_output_tokens: 16_384,
|
|
updated_at: updated_at
|
|
})
|
|
)
|
|
|
|
Repo.insert!(
|
|
BDS.AI.Model.changeset(%BDS.AI.Model{}, %{
|
|
provider: "ollama",
|
|
model_id: "llama3.3",
|
|
name: "Llama 3.3",
|
|
context_window: 128_000,
|
|
max_input_tokens: 128_000,
|
|
max_output_tokens: 8_192,
|
|
updated_at: updated_at
|
|
})
|
|
)
|
|
|
|
assert {:ok, _endpoint} =
|
|
AI.put_endpoint(:airplane, %{
|
|
url: "http://localhost:11434/v1",
|
|
api_key: nil,
|
|
model: "llama3.3"
|
|
})
|
|
|
|
assert {:ok, conversation} = AI.start_chat(%{title: "Grouped Models", model: "gpt-4o"})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "chat",
|
|
"id" => conversation.id,
|
|
"title" => conversation.title,
|
|
"subtitle" => conversation.model || "chat"
|
|
})
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='chat-model-selector-button']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-testid="chat-model-provider-group")
|
|
assert html =~ ~s(data-provider="openai")
|
|
assert html =~ ~s(data-provider="ollama")
|
|
assert html =~ ">OpenAI<"
|
|
assert html =~ ">Ollama<"
|
|
assert html =~ ">GPT-4o<"
|
|
assert html =~ ">Llama 3.3<"
|
|
end
|
|
|
|
test "chat editor renders API-key-required state when online chat is not configured" do
|
|
assert :ok = AI.set_airplane_mode(false)
|
|
|
|
assert {:ok, _endpoint} =
|
|
AI.put_endpoint(:online, %{
|
|
url: "https://api.example.test/v1",
|
|
api_key: nil,
|
|
model: "gpt-4.1"
|
|
})
|
|
|
|
assert {:ok, conversation} = AI.start_chat(%{title: "Needs Setup", model: "gpt-4.1"})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "chat",
|
|
"id" => conversation.id,
|
|
"title" => conversation.title,
|
|
"subtitle" => conversation.model || "chat"
|
|
})
|
|
|
|
assert html =~ "AI Chat Setup"
|
|
assert html =~ "API Key Required"
|
|
assert html =~ "Open Settings"
|
|
refute html =~ ~s(data-testid="chat-input-container")
|
|
end
|
|
|
|
test "chat editor renders assistant markdown and dispatches assistant navigation actions", %{project: project} do
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Action Post",
|
|
content: "Body",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, conversation} = AI.start_chat(%{title: "Action Chat", model: "gpt-4.1"})
|
|
|
|
now = Persistence.now_ms()
|
|
|
|
Repo.insert!(
|
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
|
conversation_id: conversation.id,
|
|
role: :user,
|
|
content: "Open the post",
|
|
created_at: now
|
|
})
|
|
)
|
|
|
|
Repo.insert!(
|
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
|
conversation_id: conversation.id,
|
|
role: :assistant,
|
|
content: "Use **markdown** to jump to the editor.",
|
|
tool_calls:
|
|
Jason.encode!([
|
|
%{
|
|
"id" => "call-card",
|
|
"name" => "render_card",
|
|
"arguments" => %{
|
|
"title" => "Quick Action",
|
|
"body" => "Open the related post editor.",
|
|
"actions" => [
|
|
%{
|
|
"label" => "Open Post",
|
|
"action" => "openPost",
|
|
"payload" => %{"postId" => post.id}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]),
|
|
created_at: now + 1
|
|
})
|
|
)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "chat",
|
|
"id" => conversation.id,
|
|
"title" => conversation.title,
|
|
"subtitle" => conversation.model || "chat"
|
|
})
|
|
|
|
assert html =~ "<strong>markdown</strong>"
|
|
assert html =~ ~s(data-testid="chat-inline-surface")
|
|
assert html =~ "Quick Action"
|
|
assert html =~ "Open Post"
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='chat-surface-action'][data-action='openPost']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-tab-type="post")
|
|
assert html =~ ~s(data-tab-id="#{post.id}")
|
|
end
|
|
|
|
test "chat editor shows in-flight stop state and can abort a running turn" do
|
|
assert :ok = AI.set_airplane_mode(false)
|
|
|
|
server =
|
|
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false})
|
|
|
|
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
|
|
|
|
assert {:ok, _endpoint} =
|
|
AI.put_endpoint(:online, %{
|
|
url: "http://127.0.0.1:#{port}/v1",
|
|
api_key: "online-secret",
|
|
model: "gpt-4.1"
|
|
})
|
|
|
|
assert {:ok, conversation} = AI.start_chat(%{title: "Slow Chat", model: "gpt-4.1"})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
_html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "chat",
|
|
"id" => conversation.id,
|
|
"title" => conversation.title,
|
|
"subtitle" => conversation.model || "chat"
|
|
})
|
|
|
|
_html = render_change(view, "change_chat_editor_input", %{"message" => "Please wait"})
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='chat-send-button']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-testid="chat-abort-button")
|
|
assert html =~ ~s(data-testid="chat-streaming-thinking")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='chat-abort-button']")
|
|
|> render_click()
|
|
|
|
refute html =~ ~s(data-testid="chat-abort-button")
|
|
|
|
Process.sleep(350)
|
|
refute render(view) =~ "Delayed response"
|
|
end
|
|
|
|
test "translation validation route renders dedicated cards and fix controls", %{project: project, temp_dir: temp_dir} do
|
|
assert {:ok, _metadata} =
|
|
BDS.Metadata.update_project_metadata(project.id, %{
|
|
main_language: "en",
|
|
blog_languages: ["en", "de"]
|
|
})
|
|
|
|
assert {:ok, post} =
|
|
Posts.create_post(%{
|
|
project_id: project.id,
|
|
title: "Hello",
|
|
content: "World",
|
|
language: "en"
|
|
})
|
|
|
|
assert {:ok, published_post} = Posts.publish_post(post.id)
|
|
|
|
assert {:ok, _translation} =
|
|
Posts.upsert_post_translation(post.id, "de", %{
|
|
title: "Hallo",
|
|
content: "Welt",
|
|
status: :published
|
|
})
|
|
|
|
invalid_file_path =
|
|
Path.join([
|
|
temp_dir,
|
|
Path.dirname(published_post.file_path),
|
|
"#{published_post.slug}.en.md"
|
|
])
|
|
|
|
File.write!(
|
|
invalid_file_path,
|
|
[
|
|
"---",
|
|
"translationFor: #{post.id}",
|
|
"language: en",
|
|
"title: Wrong Language",
|
|
"---",
|
|
"Invalid translation",
|
|
""
|
|
]
|
|
|> Enum.join("\n")
|
|
)
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("validate_translations")
|
|
completed_task!(queued.task_id)
|
|
send(view.pid, :refresh_task_status)
|
|
|
|
html = render(view)
|
|
|
|
assert html =~ ~s(class="translation-validation-view")
|
|
assert html =~ ~s(data-testid="translation-validation-revalidate")
|
|
assert html =~ ~s(data-testid="translation-validation-fix")
|
|
assert html =~ ~s(data-testid="translation-validation-card")
|
|
assert html =~ invalid_file_path
|
|
end
|
|
|
|
test "git diff route renders a structured Monaco diff surface for working tree changes", %{temp_dir: temp_dir} do
|
|
posts_dir = Path.join(temp_dir, "posts")
|
|
File.mkdir_p!(posts_dir)
|
|
|
|
file_path = Path.join(posts_dir, "first.md")
|
|
File.write!(file_path, "Old content\n")
|
|
|
|
init_git_repo!(temp_dir, "initial")
|
|
File.write!(file_path, "New content\n")
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "git_diff",
|
|
"id" => "git-working-tree",
|
|
"title" => "Working tree",
|
|
"subtitle" => "Working tree and history"
|
|
})
|
|
|
|
assert html =~ ~s(class="git-diff-view")
|
|
assert html =~ ~s(data-testid="git-diff-file-select")
|
|
assert html =~ "posts/first.md"
|
|
assert html =~ ~s(phx-hook="MonacoDiffEditor")
|
|
refute html =~ ~s(<pre><code>)
|
|
end
|
|
|
|
test "settings sidebar categories render the full old-app section model and target the requested section" do
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
render_click(view, "pin_sidebar_item", %{
|
|
"route" => "settings",
|
|
"id" => "settings-ai",
|
|
"title" => "AI",
|
|
"subtitle" => "Assistant settings"
|
|
})
|
|
|
|
assert html =~ ~s(id="settings-section-project")
|
|
assert html =~ ~s(id="settings-section-editor")
|
|
assert html =~ ~s(id="settings-section-content")
|
|
assert html =~ ~s(id="settings-section-ai")
|
|
assert html =~ ~s(id="settings-section-technology")
|
|
assert html =~ ~s(id="settings-section-publishing")
|
|
assert html =~ ~s(id="settings-section-data")
|
|
assert html =~ ~s(id="settings-section-mcp")
|
|
assert html =~ ~s(data-selected-settings-section="ai")
|
|
end
|
|
|
|
test "template sidebar exposes old-app style delete control and removes template rows", %{project: project} do
|
|
assert {:ok, template} =
|
|
BDS.Templates.create_template(%{
|
|
project_id: project.id,
|
|
title: "Sidebar Template",
|
|
kind: :post,
|
|
content: "<article>{{ post.content }}</article>"
|
|
})
|
|
|
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='activity-button'][data-view='templates']")
|
|
|> render_click()
|
|
|
|
assert html =~ "Sidebar Template"
|
|
assert html =~ ~s(data-testid="sidebar-delete-template")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-open-item'][data-item-id='#{template.id}']")
|
|
|> render_click()
|
|
|
|
assert html =~ ~s(data-tab-type="templates")
|
|
assert html =~ ~s(data-tab-id="#{template.id}")
|
|
|
|
html =
|
|
view
|
|
|> element("[data-testid='sidebar-delete-template'][data-item-id='#{template.id}']")
|
|
|> render_click()
|
|
|
|
assert BDS.Repo.get(BDS.Templates.Template, template.id) == nil
|
|
refute html =~ "Sidebar Template"
|
|
refute html =~ ~s(data-tab-type="templates")
|
|
refute html =~ ~s(data-tab-id="#{template.id}")
|
|
end
|
|
|
|
defp seed_sidebar_posts(project_id) do
|
|
now = Persistence.now_ms()
|
|
|
|
entries =
|
|
[
|
|
sidebar_post(project_id, "alpha-post", "Alpha Post", now + 3_000, ["tech"], ["notes"]),
|
|
sidebar_post(project_id, "beta-post", "Beta Post", now + 2_000, ["design"], ["guides"])
|
|
] ++
|
|
Enum.map(1..498, fn index ->
|
|
sidebar_post(project_id, "filler-#{index}", "Filler #{index}", now - index, ["filler"], ["archive"])
|
|
end) ++
|
|
[sidebar_post(project_id, "overflow-post", "Overflow Post", now - 10_000, ["tech"], ["notes"])]
|
|
|
|
{count, _rows} = Repo.insert_all(Post, entries)
|
|
assert count == length(entries)
|
|
end
|
|
|
|
defp html_position(html, needle) do
|
|
case :binary.match(html, needle) do
|
|
{index, _length} -> index
|
|
:nomatch -> -1
|
|
end
|
|
end
|
|
|
|
defp sidebar_post(project_id, slug, title, timestamp, tags, categories) do
|
|
%{
|
|
id: Ecto.UUID.generate(),
|
|
project_id: project_id,
|
|
title: title,
|
|
slug: slug,
|
|
excerpt: nil,
|
|
content: nil,
|
|
status: :published,
|
|
author: nil,
|
|
created_at: timestamp,
|
|
updated_at: timestamp,
|
|
published_at: timestamp,
|
|
file_path: "posts/#{slug}.md",
|
|
checksum: nil,
|
|
tags: tags,
|
|
categories: categories,
|
|
template_slug: nil,
|
|
language: "en",
|
|
do_not_translate: false,
|
|
published_title: nil,
|
|
published_content: nil,
|
|
published_tags: nil,
|
|
published_categories: nil,
|
|
published_excerpt: nil
|
|
}
|
|
end
|
|
|
|
defp canonical_post_href(post) do
|
|
datetime = DateTime.from_unix!(post.created_at, :millisecond)
|
|
|
|
Path.join([
|
|
"",
|
|
Integer.to_string(datetime.year),
|
|
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
|
|
String.pad_leading(Integer.to_string(datetime.day), 2, "0"),
|
|
post.slug,
|
|
""
|
|
])
|
|
end
|
|
|
|
defp init_git_repo!(project_dir, message) do
|
|
run_git!(project_dir, ["init", "-b", "master"])
|
|
run_git!(project_dir, ["config", "user.name", "bDS Tests"])
|
|
run_git!(project_dir, ["config", "user.email", "tests@example.com"])
|
|
run_git!(project_dir, ["add", "-A"])
|
|
run_git!(project_dir, ["commit", "-m", message])
|
|
end
|
|
|
|
defp run_git!(dir, args) do
|
|
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
|
|
|
|
assert status == 0, output
|
|
end
|
|
end
|