defmodule BDS.Desktop.ShellLiveTest do use ExUnit.Case, async: false import ExUnit.CaptureLog import Phoenix.ConnTest import Phoenix.LiveViewTest @shell_live_source_root Path.expand("../../../lib/bds/desktop/shell_live", __DIR__) @endpoint BDS.Desktop.Endpoint @css_source_files [ "tokens.css", "shell.css", "sidebar.css", "tabs.css", "editor.css", "forms.css", "panel.css", "assistant.css", "overlays.css", "menu_editor.css", "media_editor.css", "import_editor.css", "utilities.css" ] defp desktop_css_source do @css_source_files |> Enum.map(&File.read!(Path.expand("../../../assets/css/#{&1}", __DIR__))) |> Enum.join("\n") end defp phase3_post_editor_assigns do %{ myself: nil, post_editor: %{ id: 42, dirty?: true, display_title: "Phase 3 Post", status: :draft, save_state: :saving, quick_actions_open?: false, can_publish?: true, discard_title: "Discard draft", discard_label: "Discard", can_delete?: true, metadata_expanded: true, translation_flags: [ %{language: "en", status: :draft, active: true, label: "English", flag: "EN"} ], form: %{ "title" => "Phase 3 Post", "author" => "Author", "language" => "en", "do_not_translate" => false, "template_slug" => "", "excerpt" => "Excerpt", "content" => "# Hello", "tags" => "elixir", "categories" => "news" }, tag_chips: [%{name: "elixir", color: "#3b82f6"}], tag_query: "", tag_suggestions: [], tag_query_addable?: false, languages: ["en", "de"], detect_language_enabled?: true, slug: "phase-3-post", category_values: ["news"], category_query: "", category_suggestions: [], category_query_addable?: false, show_template_selector?: true, template_options: [%{slug: "default", title: "Default"}], post_links: %{backlinks: [], outlinks: []}, linked_media: [], excerpt_expanded: true, mode: :markdown, gallery_count: 1, preview_url: nil, footer: %{created_at: "2026-05-04", updated_at: "2026-05-04", published_at: nil}, can_translate?: true } } end defp phase3_media_editor_assigns do %{ myself: nil, media_editor: %{ dirty?: true, display_title: "Hero Image", save_state: :saved, quick_actions_open?: false, is_image: true, can_detect_language?: true, can_translate?: true, preview_url: "/media/hero.jpg", form: %{ "title" => "Hero Image", "alt" => "Hero alt", "caption" => "Caption", "tags" => "cover", "author" => "Author", "language" => "en" }, original_name: "hero.jpg", mime_type: "image/jpeg", file_size: "42 KB", dimensions: "1200x800", languages: ["en", "de"], translations: [], post_picker_open?: false, post_picker_query: "", post_picker_results: [], post_picker_overflow_count: 0, linked_posts: [], editing_translation: nil } } end defp phase3_script_editor_assigns do %{ myself: nil, script_editor: %{ id: 7, title: "Build Feed", slug: "build-feed", kind: "utility", entrypoint: "run", enabled: true, content: "print('ok')", entrypoints: ["run"], status: :draft, can_publish?: true, created_at: 1_714_816_000, updated_at: 1_714_816_000 } } end defp phase3_template_editor_assigns do %{ myself: nil, template_editor: %{ id: 9, title: "Post Template", slug: "post-template", kind: :post, enabled: true, content: "{{ content }}", status: :draft, can_publish?: true, created_at: 1_714_816_000, updated_at: 1_714_816_000 } } end defp phase3_chat_editor_assigns do %{ myself: nil, chat_editor: %{ id: 5, needs_api_key?: false, title: "AI Assistant", effective_model: "gpt-4.1", model_selector_open?: false, available_models: [], available_model_groups: [], messages: [], is_streaming: false, pending_user_message: nil, streaming_content: "", streaming_tool_markers: [], streaming_inline_surfaces: [], input: "", send_disabled?: true, action_error: nil } } end defp phase3_menu_editor_assigns do %{ myself: nil, menu_editor: %{ draft: nil, title: "Navigation", description: "Manage site navigation", can_move_up?: false, can_move_down?: false, can_indent?: false, can_unindent?: false, can_delete?: false, items: [] } } end defp phase3_settings_editor_assigns do %{ myself: nil, current_tab: %{type: :settings, id: "settings"}, settings_editor: %{ selected_section: "project", search_query: "", active_sections: ["project"], project_visible?: true, editor_visible?: false, content_visible?: false, ai_visible?: false, publishing_visible?: false, data_visible?: false, technology_visible?: false, mcp_visible?: false, project: %{ "name" => "Shell Project", "description" => "Project settings", "public_url" => "https://example.test", "main_language" => "en", "blog_languages" => ["en", "fr"], "default_author" => "Author", "max_posts_per_page" => 10, "blogmark_category" => "notes" }, project_data_path: "/tmp/shell-project", supported_languages: ["en", "fr"], categories: [%{name: "notes"}, %{name: "posts"}] } } end defp phase3_tags_editor_assigns do %{ myself: nil, tags_editor: %{ selected_section: "cloud", tags: [], new_tag: %{"name" => "", "color" => "#3b82f6"}, edit_draft: %{"name" => "news", "color" => "#3b82f6", "post_template_slug" => ""}, selected: ["news", "updates"], merge_target: "news", templates: [%{slug: "post-template", title: "Post Template"}] } } end test "shell live modules use contexts instead of direct Repo.get calls" do source_files = [ Path.expand("../../../lib/bds/desktop/shell_live.ex", __DIR__) | Path.wildcard(Path.join(@shell_live_source_root, "**/*.ex")) ] offenders = source_files |> Enum.flat_map(fn path -> path |> File.read!() |> String.split("\n") |> Enum.with_index(1) |> Enum.filter(fn {line, _line_number} -> String.contains?(line, "Repo.get(") or String.contains?(line, "Repo.get!(") end) |> Enum.map(fn {_line, line_number} -> "#{Path.relative_to_cwd(path)}:#{line_number}" end) end) assert offenders == [] end @tag :phase3 test "phase 3 shell chrome renders utility-owned layout classes" do conn = Plug.Conn.put_private(build_conn(), :phoenix_endpoint, BDS.Desktop.Endpoint) {:ok, _view, html} = live_isolated(conn, BDS.Desktop.ShellLive) assert html =~ "activity-bar flex h-full shrink-0 flex-col items-center" assert html =~ "sidebar-shell flex min-w-0 shrink-0 overflow-hidden" assert html =~ "tab-bar-empty flex h-full items-center px-3 text-sm" assert html =~ "assistant-sidebar-shell flex min-w-0 shrink-0 overflow-hidden" assert html =~ "status-bar flex h-[22px] shrink-0 items-center justify-between" end @tag :phase3 test "phase 3 editors and shared surfaces render utility-owned layouts" do post_html = render_component(&BDS.Desktop.ShellLive.PostEditor.render/1, phase3_post_editor_assigns()) media_html = render_component(&BDS.Desktop.ShellLive.MediaEditor.render/1, phase3_media_editor_assigns()) script_html = render_component(&BDS.Desktop.ShellLive.ScriptEditor.render/1, phase3_script_editor_assigns()) template_html = render_component(&BDS.Desktop.ShellLive.TemplateEditor.render/1, phase3_template_editor_assigns()) chat_html = render_component(&BDS.Desktop.ShellLive.ChatEditor.render/1, phase3_chat_editor_assigns()) menu_html = render_component(&BDS.Desktop.ShellLive.MenuEditor.render/1, phase3_menu_editor_assigns()) settings_html = render_component(&BDS.Desktop.ShellLive.SettingsEditor.render/1, phase3_settings_editor_assigns()) tags_html = render_component(&BDS.Desktop.ShellLive.TagsEditor.render/1, phase3_tags_editor_assigns()) assert post_html =~ "post-editor ui-editor-shell flex h-full min-h-0 flex-col" assert post_html =~ "editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3" assert post_html =~ "editor-field ui-field-stack flex flex-col gap-1.5" assert post_html =~ "editor-toolbar ui-toolbar flex items-center gap-3" assert media_html =~ "media-editor ui-editor-shell flex h-full min-h-0 flex-col" assert media_html =~ "editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4" assert script_html =~ "scripts-view-shell ui-editor-shell flex h-full min-h-0 flex-col" assert script_html =~ "flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4" assert template_html =~ "templates-view-shell ui-editor-shell flex h-full min-h-0 flex-col" assert template_html =~ "flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4" assert chat_html =~ "chat-panel ui-editor-shell flex h-full min-h-0 flex-col" assert chat_html =~ "chat-panel-header flex shrink-0 items-center justify-between gap-3" assert menu_html =~ "menu-editor-view ui-editor-shell flex h-full min-h-0 flex-col p-4" assert menu_html =~ "menu-editor-toolbar ui-toolbar flex flex-wrap items-center gap-2" assert settings_html =~ "settings-view-shell ui-editor-shell flex h-full min-h-0 flex-col overflow-hidden" assert settings_html =~ "settings-header flex shrink-0 items-center justify-between gap-3" assert tags_html =~ "tags-view-shell flex h-full min-h-0 flex-col overflow-hidden" assert tags_html =~ "tag-form-row flex flex-wrap items-center gap-3" end @tag :phase4 test "phase 4 shared primitives render normalized classes" do conn = Plug.Conn.put_private(build_conn(), :phoenix_endpoint, BDS.Desktop.Endpoint) {:ok, view, _shell_html} = live_isolated(conn, BDS.Desktop.ShellLive) post_html = render_component(&BDS.Desktop.ShellLive.PostEditor.render/1, phase3_post_editor_assigns()) media_html = render_component(&BDS.Desktop.ShellLive.MediaEditor.render/1, phase3_media_editor_assigns()) script_html = render_component(&BDS.Desktop.ShellLive.ScriptEditor.render/1, phase3_script_editor_assigns()) template_html = render_component(&BDS.Desktop.ShellLive.TemplateEditor.render/1, phase3_template_editor_assigns()) settings_html = render_component(&BDS.Desktop.ShellLive.SettingsEditor.render/1, phase3_settings_editor_assigns()) tags_html = render_component(&BDS.Desktop.ShellLive.TagsEditor.render/1, phase3_tags_editor_assigns()) panel_html = render_component(&BDS.Desktop.ShellLive.PanelRenderer.render_panel_body/1, %{ current_tab: %{type: :dashboard, id: "dashboard"}, task_status: %{tasks: []}, output_entries: [], workbench: %{panel: %{active_tab: :tasks}} }) assert post_html =~ ~s(class="status-badge ui-badge) assert post_html =~ ~s(class="success ui-button ui-button-primary) assert post_html =~ ~s(class="secondary danger ui-button ui-button-secondary ui-button-danger) assert post_html =~ ~s(class="post-editor-input ui-input) assert post_html =~ ~s(class="post-editor-textarea post-editor-excerpt ui-textarea) assert post_html =~ "ui-tab ui-tab-active ui-editor-tab-current" assert media_html =~ ~s(class="secondary quick-actions-btn ui-button ui-button-secondary) assert media_html =~ ~s(class="post-editor-input ui-input disabled ui-input-disabled) assert media_html =~ ~s(class="post-editor-textarea ui-textarea) assert script_html =~ ~s(class="secondary scripts-save-button ui-button ui-button-secondary) assert script_html =~ ~s(class="status-badge ui-badge) assert script_html =~ ~s(class="ui-input") assert template_html =~ ~s(class="secondary templates-save-button ui-button ui-button-secondary) assert template_html =~ ~s(class="status-badge ui-badge) assert template_html =~ ~s(class="ui-input") assert settings_html =~ ~s(class="ui-input") assert settings_html =~ ~s(class="primary ui-button ui-button-primary") assert settings_html =~ ~s(class="secondary ui-button ui-button-secondary") assert tags_html =~ ~s(class="tags-empty-state ui-empty-state flex flex-col gap-3") assert tags_html =~ ~s(class="secondary ui-button ui-button-secondary") assert tags_html =~ ~s(class="primary ui-button ui-button-primary") assert tags_html =~ ~s(class="danger ui-button ui-button-danger") assert tags_html =~ ~s(class="ui-input") shell_html = view |> element("[data-testid='toggle-panel']") |> render_click() assert shell_html =~ ~s(class="panel-tab ui-tab ui-tab-active) assert panel_html =~ ~s(class="panel-entry ui-panel-entry panel-empty-state ui-empty-state) end @tag :phase5 test "phase 5 desktop-specific surfaces keep shell, media, menu, and chat contracts" do conn = Plug.Conn.put_private(build_conn(), :phoenix_endpoint, BDS.Desktop.Endpoint) {:ok, _view, shell_html} = live_isolated(conn, BDS.Desktop.ShellLive) media_html = render_component(&BDS.Desktop.ShellLive.MediaEditor.render/1, phase3_media_editor_assigns()) chat_html = render_component(&BDS.Desktop.ShellLive.ChatEditor.render/1, phase3_chat_editor_assigns()) menu_html = render_component(&BDS.Desktop.ShellLive.MenuEditor.render/1, phase3_menu_editor_assigns()) assert shell_html =~ ~s(class="assistant-sidebar-context flex shrink-0 flex-col gap-2") assert shell_html =~ ~s(class="assistant-sidebar-prompt min-h-[8rem] w-full resize-y") assert shell_html =~ ~s(class="assistant-sidebar-start-button ui-button ui-button-primary") assert shell_html =~ ~s(class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto") assert media_html =~ "class=\"editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4 xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]\"" assert media_html =~ "class=\"media-preview flex min-h-[16rem] items-center justify-center\"" assert media_html =~ ~s(class="media-details min-w-0") assert chat_html =~ ~s(class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col") assert chat_html =~ ~s(class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2") assert chat_html =~ ~s(class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3") assert chat_html =~ ~s(class="chat-input-wrapper flex items-end gap-2") assert menu_html =~ ~s(class="menu-editor-view ui-editor-shell flex h-full min-h-0 flex-col p-4") assert menu_html =~ ~s(class="menu-editor-toolbar ui-toolbar flex flex-wrap items-center gap-2") assert menu_html =~ ~s(class="menu-editor-empty flex min-h-0 flex-1 items-center justify-center") end alias BDS.Persistence alias BDS.AI alias BDS.CliSync.Watcher alias BDS.Menu 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 GatedChatServer do @moduledoc false use Plug.Router import Phoenix.ConnTest, except: [post: 2] plug(:match) plug(:dispatch) def hold_gate do case GenServer.whereis(__MODULE__) do nil -> :ok pid -> Agent.stop(pid) end Agent.start_link(fn -> :hold end, name: __MODULE__) end def release_gate, do: Agent.update(__MODULE__, fn _ -> :release end) post "/v1/chat/completions" do wait_for_release() 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 defp wait_for_release do case GenServer.whereis(__MODULE__) do nil -> Process.sleep(:infinity) _pid -> case Agent.get(__MODULE__, & &1) do :release -> :ok :hold -> Process.sleep(50) && wait_for_release() end end end end defmodule TitleChatServer do use Plug.Router import Phoenix.ConnTest, except: [post: 2] plug(:match) plug(:dispatch) post "/v1/chat/completions" do {:ok, request_body, conn} = Plug.Conn.read_body(conn) request = Jason.decode!(request_body) send(Application.fetch_env!(:bds, :test_pid), {:title_chat_request, request}) content = if Enum.any?(request["messages"] || [], fn message -> String.contains?(message["content"] || "", "Generate an ultra-short title") end) do "Posts 2026" else "Ich habe die Posts pro Monat ermittelt." end body = Jason.encode!(%{ "choices" => [%{"message" => %{"content" => content}}], "usage" => %{"prompt_tokens" => 8, "completion_tokens" => 5} }) conn |> Plug.Conn.put_resp_content_type("application/json") |> send_resp(200, body) end end defmodule AiSuggestionsServer do use Plug.Router import Phoenix.ConnTest, except: [post: 2] plug(:match) plug(:dispatch) post "/v1/chat/completions" do {:ok, request_body, conn} = Plug.Conn.read_body(conn) request = Jason.decode!(request_body) send(Application.fetch_env!(:bds, :test_pid), {:ai_suggestions_request, request}) operation = get_in(request, ["messages", Access.at(0), "content"]) || "" content = cond do String.contains?(operation, "image") -> Jason.encode!(%{ "title" => "AI Image Title", "alt" => "AI Alt Text", "caption" => "AI Caption" }) true -> Jason.encode!(%{ "title" => "AI Suggested Title", "excerpt" => "AI Suggested Excerpt", "slug" => "ai-suggested-slug" }) end body = Jason.encode!(%{ "choices" => [%{"message" => %{"content" => content}}], "usage" => %{"prompt_tokens" => 20, "completion_tokens" => 10} }) conn |> Plug.Conn.put_resp_content_type("application/json") |> send_resp(200, body) end end setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) Enum.each(BDS.Tasks.list_running_tasks(), fn task -> BDS.Tasks.cancel_task(task.id) end) if :ets.whereis(:bds_ai_in_flight) != :undefined do Enum.each(:ets.tab2list(:bds_ai_in_flight), fn {_conversation_id, pid} -> Process.exit(pid, :kill) end) end for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.TCP.TaskSupervisor) do DynamicSupervisor.terminate_child(BDS.TCP.TaskSupervisor, pid) end for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.Tasks.TaskSupervisor) do DynamicSupervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid) end Process.sleep(100) 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, chat, 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='chat']") |> render_click() assert html =~ ~s(data-sidebar-action="chat") 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, chat, 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) chat_count_before = Repo.aggregate(BDS.AI.ChatConversation, :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" => "chat"}) html = view |> element("[data-testid='sidebar-create-action'][data-sidebar-action='chat']") |> render_click() assert Repo.aggregate(BDS.AI.ChatConversation, :count, :id) == chat_count_before + 1 created_chat = Repo.one!(BDS.AI.ChatConversation) assert created_chat.title == "New Chat" assert html =~ ~s(data-tab-type="chat") assert html =~ ~s(data-tab-id="#{created_chat.id}") html = render_click(view, "select_view", %{"view" => "chat"}) assert html =~ ~s(data-testid="sidebar-delete-chat") html = view |> element("[data-testid='sidebar-delete-chat'][data-item-id='#{created_chat.id}']") |> render_click() assert Repo.get(BDS.AI.ChatConversation, created_chat.id) assert html =~ "confirm-delete-modal" assert html =~ created_chat.title html = render_click(view, "overlay_confirm", %{}) refute Repo.get(BDS.AI.ChatConversation, created_chat.id) refute html =~ ~s(data-tab-id="#{created_chat.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 "settings sidebar selections expose a scroll target for the preferences editor" do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) _html = render_click(view, "select_view", %{"view" => "settings"}) html = view |> element("[data-testid='sidebar-open-item'][data-item-id='settings-ai']") |> render_click() assert html =~ ~s(phx-hook="SettingsSectionScroll") assert html =~ ~s(data-selected-settings-section="ai") assert html =~ ~s(data-settings-scroll-target="settings-section-ai") end test "tags sidebar selections expose a scroll target for the tags editor" do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) _html = render_click(view, "select_view", %{"view" => "tags"}) html = view |> element("[data-testid='sidebar-open-item'][data-item-id='tags-merge']") |> render_click() assert html =~ ~s(data-testid="tags-editor") assert html =~ ~s(phx-hook="TagsSectionScroll") assert html =~ ~s(data-selected-tags-section="merge") assert html =~ ~s(data-tags-scroll-target="tags-section-merge") end test "tags discover materializes post tags and enables merge from the tags editor", %{ project: project } do assert {:ok, post} = Posts.create_post(%{ project_id: project.id, title: "Tagged Post", content: "Body", tags: ["Alpha", "Beta"] }) assert Tags.list_tags(project.id) == [] {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) _html = render_click(view, "select_view", %{"view" => "tags"}) _html = view |> element("[data-testid='sidebar-open-item'][data-item-id='tags-cloud']") |> render_click() html = view |> element("#tags-section-sync button[phx-click='sync_tags_editor']") |> render_click() assert Enum.map(Tags.list_tags(project.id), & &1.name) == ["Alpha", "Beta"] assert html =~ "Alpha" assert html =~ "Beta" _html = view |> element( "#tags-editor-shell button[phx-click='toggle_tag_selection'][phx-value-name='Alpha']" ) |> render_click() _html = view |> element( "#tags-editor-shell button[phx-click='toggle_tag_selection'][phx-value-name='Beta']" ) |> render_click() _html = view |> element("#tags-editor-shell select[phx-change='change_merge_target']") |> render_change(%{"target" => "Alpha"}) html = view |> element("#tags-editor-shell button[phx-click='merge_tags_editor']") |> render_click() assert Enum.map(Tags.list_tags(project.id), & &1.name) == ["Alpha"] assert Repo.get!(Post, post.id).tags == ["Alpha"] assert html =~ "Alpha" end test "database-backed sidebar entries require confirmation before deletion", %{ project: project, temp_dir: temp_dir } do assert {:ok, post} = Posts.create_post(%{ project_id: project.id, title: "Sidebar Delete Post", content: "delete me" }) media_source_path = Path.join(temp_dir, "sidebar-delete-media.txt") File.write!(media_source_path, "media body") assert {:ok, media} = Media.import_media(%{ project_id: project.id, source_path: media_source_path, title: "Sidebar Delete Media" }) assert {:ok, script} = Scripts.create_script(%{ project_id: project.id, title: "Sidebar Delete Script", kind: :utility, content: "print(\"delete\")", entrypoint: "main", enabled: true }) assert {:ok, template} = Templates.create_template(%{ project_id: project.id, title: "Sidebar Delete Template", kind: :post, content: "
{{ post.content }}
", enabled: true }) assert {:ok, conversation} = AI.start_chat(%{title: "Sidebar Delete Chat"}) assert {:ok, definition} = ImportDefinitions.create_definition(%{ project_id: project.id, name: "Sidebar Delete Import" }) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) cases = [ %{ view: "posts", id: post.id, title: post.title, testid: "sidebar-delete-post", exists?: fn -> Repo.get(Post, post.id) != nil end }, %{ view: "media", id: media.id, title: media.title, testid: "sidebar-delete-media", exists?: fn -> Repo.get(BDS.Media.Media, media.id) != nil end }, %{ view: "scripts", id: script.id, title: script.title, testid: "sidebar-delete-script", exists?: fn -> Repo.get(BDS.Scripts.Script, script.id) != nil end }, %{ view: "templates", id: template.id, title: template.title, testid: "sidebar-delete-template", exists?: fn -> Repo.get(BDS.Templates.Template, template.id) != nil end }, %{ view: "chat", id: conversation.id, title: conversation.title, testid: "sidebar-delete-chat", exists?: fn -> Repo.get(BDS.AI.ChatConversation, conversation.id) != nil end }, %{ view: "import", id: definition.id, title: definition.name, testid: "sidebar-delete-import", exists?: fn -> Repo.get(ImportDefinitions.ImportDefinition, definition.id) != nil end } ] Enum.each(cases, fn sidebar_case -> html = render_click(view, "select_view", %{"view" => sidebar_case.view}) assert html =~ ~s(data-testid="#{sidebar_case.testid}") html = view |> element("[data-testid='#{sidebar_case.testid}'][data-item-id='#{sidebar_case.id}']") |> render_click() assert sidebar_case.exists?.() assert html =~ "confirm-delete-modal" assert html =~ sidebar_case.title html = render_click(view, "overlay_confirm", %{}) refute sidebar_case.exists?.() refute html =~ sidebar_case.title end) end test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change", %{project: project} do {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) refute html =~ "CLI Added Post" assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Added Post"}) Phoenix.PubSub.broadcast( BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :created}} ) assert render(view) =~ "CLI Added Post" end test "shell live closes stale post and media tabs when the CLI watcher broadcasts deletions", %{ project: project, temp_dir: temp_dir } do assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Delete Post"}) source_path = Path.join(temp_dir, "cli-delete-media.txt") File.write!(source_path, "media body") assert {:ok, media} = Media.import_media(%{ project_id: project.id, source_path: source_path, title: "CLI Delete Media" }) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) html = view |> element("[data-testid='sidebar-open-item'][data-item-id='#{post.id}']") |> render_click() assert html =~ ~s(data-tab-type="post") assert html =~ ~s(data-tab-id="#{post.id}") assert {:ok, :deleted} = Posts.delete_post(post.id) Phoenix.PubSub.broadcast( BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :deleted}} ) html = render(view) refute html =~ ~s(data-tab-type="post") refute html =~ "CLI Delete Post" _html = view |> element("[data-testid='activity-button'][data-view='media']") |> render_click() html = view |> element("[data-testid='sidebar-open-item'][data-item-id='#{media.id}']") |> render_click() assert html =~ ~s(data-tab-type="media") assert html =~ ~s(data-tab-id="#{media.id}") assert {:ok, :deleted} = Media.delete_media(media.id) Phoenix.PubSub.broadcast( BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "media", entity_id: media.id, action: :deleted}} ) html = render(view) refute html =~ ~s(data-tab-type="media") refute html =~ "CLI Delete Media" 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 flex min-h-0 shrink-0 flex-col overflow-hidden 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 flex min-w-0 shrink-0 overflow-hidden is-hidden") html = view |> element("[data-testid='toggle-sidebar']") |> render_click() refute html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden") html = view |> element("[data-testid='toggle-panel']") |> render_click() assert html =~ ~s(data-region="panel") refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden") assert html =~ ~s(data-testid="panel-close") html = view |> element("[data-testid='panel-close']") |> render_click() assert html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden 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 flex h-full items-center px-3 text-sm") 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 flex min-w-0 shrink-0 overflow-hidden 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 "native edit menu action opens the dedicated menu editor surface", %{project: project} do assert {:ok, _menu} = Menu.update_menu(project.id, [ %{kind: :page, label: "About", slug: "about"}, %{ kind: :submenu, label: "Sections", children: [ %{kind: :page, label: "Contact", slug: "contact"} ] } ]) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) html = render_hook(view, "native_menu_action", %{"action" => "edit_menu"}) assert html =~ ~s(data-testid="menu-editor") assert html =~ "Blog Menu Editor" assert html =~ "meta/menu.opml" assert html =~ ~s(data-testid="menu-editor-toolbar") assert html =~ ~s(data-testid="menu-editor-toolbar-button") assert html =~ ~s(data-action="add-entry") assert html =~ ~s(data-action="save") assert html =~ ~s(data-action="indent") assert html =~ ~s(data-action="unindent") assert html =~ ~s(data-testid="menu-editor-row") assert html =~ ~s(data-menu-label="Home") assert html =~ ~s(data-menu-label="About") assert html =~ ~s(data-menu-label="Sections") refute html =~ "Desktop workbench content routed through the Elixir shell." end test "native metadata diff action queues the maintenance task" do :ok = BDS.Tasks.clear_finished() {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id)) _html = render_hook(view, "native_menu_action", %{"action" => "metadata_diff"}) assert %{} = new_task!(existing_ids, "Metadata Diff") end test "native new post action reuses the sidebar create flow" do count_before = Repo.aggregate(Post, :count, :id) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) _html = render_hook(view, "native_menu_action", %{"action" => "new_post"}) assert Repo.aggregate(Post, :count, :id) == count_before + 1 end test "native save action persists the active post editor", %{project: project} do {:ok, post} = Posts.create_post(%{ project_id: project.id, title: "Draft Shell Post", content: "Initial body", excerpt: "Initial excerpt" }) {: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" }) _html = view |> form("[data-testid='post-editor-form']", %{ post_editor: %{ title: "Saved Through Menu", content: "Saved body", excerpt: "Saved excerpt", tags: "", categories: "", author: "", language: "en", do_not_translate: "false" } }) |> render_change() _html = render_hook(view, "native_menu_action", %{"action" => "save"}) _html = render(view) saved_post = Posts.get_post!(post.id) assert saved_post.title == "Saved Through Menu" assert saved_post.content == "Saved body" assert saved_post.excerpt == "Saved excerpt" end test "menu editor adds a submenu, nests an entry, and saves the opml", %{ project: project, temp_dir: temp_dir } do assert {:ok, _menu} = Menu.update_menu(project.id, [ %{kind: :page, label: "Contact", slug: "contact"} ]) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) _html = render_hook(view, "native_menu_action", %{"action" => "edit_menu"}) html = view |> element("[data-testid='menu-editor-toolbar-button'][data-action='add-entry']") |> render_click() assert html =~ ~s(data-testid="menu-editor-entry-form") html = view |> form("[data-testid='menu-editor-entry-form']", %{ menu_editor_entry: %{"query" => "Sections"} }) |> render_change() assert html =~ ~s(value="Sections") html = view |> form("[data-testid='menu-editor-entry-form']", %{ menu_editor_entry: %{"query" => "Sections"} }) |> render_submit() assert html =~ ~s(data-menu-label="Sections") html = view |> element("[data-testid='menu-editor-row'][data-menu-label='Contact']") |> render_click() assert html =~ ~s(data-selected="true") _html = view |> element("[data-testid='menu-editor-toolbar-button'][data-action='indent']") |> render_click() _html = view |> element("[data-testid='menu-editor-toolbar-button'][data-action='save']") |> render_click() assert {:ok, menu} = Menu.get_menu(project.id) assert menu.items == [ %{kind: :home, label: "Home", slug: nil}, %{ kind: :submenu, label: "Sections", slug: nil, children: [ %{kind: :page, label: "Contact", slug: "contact"} ] } ] opml_path = Path.join([temp_dir, "meta", "menu.opml"]) contents = File.read!(opml_path) assert contents =~ ~s() assert contents =~ ~s( 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 Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html) end test "workbench session restore renders documentation tab content" do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) session_payload = Workbench.new() |> Workbench.open_tab(:documentation, "documentation", :pin) |> Session.serialize() _html = render_hook(view, "restore_workbench_session", %{"session" => session_payload}) assert has_element?(view, ".tab[data-tab-type='documentation'] .tab-title", "Documentation") assert has_element?(view, "[data-testid='help-documentation']") assert has_element?(view, ".documentation-content.markdown-body .documentation-article") assert render(view) =~ "bDS2 User Guide" end test "workbench session restore renders api documentation tab content" do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) session_payload = Workbench.new() |> Workbench.open_tab(:api_documentation, "api_documentation", :pin) |> Session.serialize() _html = render_hook(view, "restore_workbench_session", %{"session" => session_payload}) assert has_element?( view, ".tab[data-tab-type='api_documentation'] .tab-title", "Api Documentation" ) assert has_element?(view, "[data-testid='help-api-documentation']") assert has_element?(view, ".documentation-content.markdown-body .documentation-article") assert render(view) =~ "API Documentation" assert render(view) =~ "local result = bds.posts.get" end test "workbench session restore rehydrates chat tab titles from stored conversations" do assert {:ok, conversation} = AI.start_chat(%{title: "Editorial Plan"}) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) session_payload = Workbench.new() |> Workbench.open_tab(:chat, conversation.id, :pin) |> Session.serialize() _html = render_hook(view, "restore_workbench_session", %{"session" => session_payload}) assert has_element?( view, ".tab[data-tab-type='chat'][data-tab-id='#{conversation.id}'] .tab-title", "Editorial Plan" ) assert has_element?(view, ".chat-panel-title-main", "Editorial Plan") end test "workbench session restore rehydrates entity tab titles from backing records", %{ project: project, temp_dir: temp_dir } do assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "Restored Post"}) source_path = Path.join(temp_dir, "restored-media.txt") File.write!(source_path, "media body") assert {:ok, media} = Media.import_media(%{ project_id: project.id, source_path: source_path, title: "Restored Media" }) assert {:ok, script} = Scripts.create_script(%{ project_id: project.id, title: "Restored Script", kind: :utility, content: "print(\"ok\")", entrypoint: "main", enabled: true }) assert {:ok, template} = Templates.create_template(%{ project_id: project.id, title: "Restored Template", kind: :post, content: "", enabled: true }) assert {:ok, definition} = ImportDefinitions.create_definition(%{ project_id: project.id, name: "Restored Import" }) assert {:ok, conversation} = AI.start_chat(%{title: "Restored Chat"}) posts_dir = Path.join(temp_dir, "posts") File.mkdir_p!(posts_dir) git_file_path = Path.join(posts_dir, "restore.md") File.write!(git_file_path, "Old content\n") init_git_repo!(temp_dir, "initial") File.write!(git_file_path, "New content\n") {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) session_payload = Workbench.new() |> Workbench.open_tab(:post, post.id, :pin) |> Workbench.open_tab(:media, media.id, :pin) |> Workbench.open_tab(:scripts, script.id, :pin) |> Workbench.open_tab(:templates, template.id, :pin) |> Workbench.open_tab(:import, definition.id, :pin) |> Workbench.open_tab(:chat, conversation.id, :pin) |> Workbench.open_tab(:git_diff, "git-working-tree", :pin) |> Session.serialize() _html = render_hook(view, "restore_workbench_session", %{"session" => session_payload}) assert has_element?( view, ".tab[data-tab-type='post'][data-tab-id='#{post.id}'] .tab-title", "Restored Post" ) assert has_element?( view, ".tab[data-tab-type='media'][data-tab-id='#{media.id}'] .tab-title", "Restored Media" ) assert has_element?( view, ".tab[data-tab-type='scripts'][data-tab-id='#{script.id}'] .tab-title", "Restored Script" ) assert has_element?( view, ".tab[data-tab-type='templates'][data-tab-id='#{template.id}'] .tab-title", "Restored Template" ) assert has_element?( view, ".tab[data-tab-type='import'][data-tab-id='#{definition.id}'] .tab-title", "Restored Import" ) assert has_element?( view, ".tab[data-tab-type='chat'][data-tab-id='#{conversation.id}'] .tab-title", "Restored Chat" ) assert has_element?( view, ".tab[data-tab-type='git_diff'][data-tab-id='git-working-tree'] .tab-title", "Working tree" ) 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" assert html =~ "Online Chat Reasoning" assert html =~ "Offline Chat Reasoning" refute html =~ "Mistral API Key" refute html =~ "Anthropic / Online API Key" _html = view |> element("#settings-editor-shell form[phx-change='change_settings_ai']") |> render_change(%{ "settings_ai" => %{ "online_url" => "https://api.example.test/v1", "online_api_key" => "online-secret", "online_chat_model" => "gpt-4.1", "online_chat_tools" => "true", "online_chat_disable_reasoning" => "true", "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_chat_tools" => "true", "offline_chat_disable_reasoning" => "true", "offline_title_model" => "llama3.2", "offline_image_analysis_model" => "llava:latest", "offline_mode" => "true", "system_prompt" => "You are the local test prompt." } }) _html = view |> element("#settings-editor-shell button[phx-click='save_settings_ai']") |> render_click() 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) assert %{supports_tool_calls: true, disables_reasoning: true} = BDS.AI.Catalog.model_capabilities("gpt-4.1") assert %{supports_tool_calls: true, disables_reasoning: true} = BDS.AI.Catalog.model_capabilities("llama3.3") 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 = view |> element("#settings-editor-shell form[phx-change='change_settings_ai']") |> render_change(%{ "settings_ai" => %{ "online_url" => "https://api.example.test/v1", "offline_url" => "http://localhost:11434/v1" } }) html = view |> element( "#settings-editor-shell button[phx-click='refresh_settings_ai_models'][phx-value-endpoint='online']" ) |> render_click() assert html =~ ~s() assert html =~ ~s() html = view |> element( "#settings-editor-shell button[phx-click='refresh_settings_ai_models'][phx-value-endpoint='airplane']" ) |> render_click() assert html =~ ~s() assert html =~ ~s() 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 Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html) 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 Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html) 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 flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden") html = render_keydown(view, "shortcut", %{key: "b", meta: true}) assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden") html = render_keydown(view, "shortcut", %{key: "j", meta: true}) refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden 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 flex min-w-0 shrink-0 overflow-hidden 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 flex items-center justify-between gap-2") assert html =~ ~s(class="sidebar-actions flex items-center gap-1") 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 flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden") html = view |> element("[data-testid='status-task-button']") |> render_click() refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden") assert Regex.match?( ~r/