feat: more work on UI app

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-24 15:27:48 +02:00
parent 1b5a5008eb
commit 0b625491cf
17 changed files with 1786 additions and 1343 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
/doc/
/.elixir_ls/
/erl_crash.dump
/node_modules/
/priv/data/*.db
/priv/data/*.db-shm
/priv/data/*.db-wal

View File

@@ -5,24 +5,17 @@ defmodule BDS.Application do
def desktop_children(env \\ nil)
def desktop_children(:test), do: []
def desktop_children(:test) do
if desktop_automation?() do
[{BDS.Desktop.Server, []}]
else
[]
end
end
def desktop_children(_env) do
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
[
{BDS.Desktop.Server, []},
{Desktop.Window,
[
app: :bds,
id: BDS.Desktop.MainWindow,
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900},
min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700},
menubar: BDS.Desktop.MenuBar,
icon_menu: BDS.Desktop.Menu,
url: &BDS.Desktop.url/0
]}
]
[{BDS.Desktop.Server, []} | desktop_window_children()]
else
[]
end
@@ -50,4 +43,28 @@ defmodule BDS.Application do
Application.get_env(:bds, :current_env_override) ||
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
end
defp desktop_window_children do
if desktop_automation?() do
[]
else
[
{Desktop.Window,
[
app: :bds,
id: BDS.Desktop.MainWindow,
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900},
min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700},
menubar: BDS.Desktop.MenuBar,
icon_menu: BDS.Desktop.Menu,
url: &BDS.Desktop.url/0
]}
]
end
end
defp desktop_automation? do
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
end
end

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop do
@moduledoc false
def url do
Application.get_env(:bds, :desktop)[:port]
BDS.Desktop.Server.port()
|> url()
end

View File

@@ -0,0 +1,320 @@
defmodule BDS.Desktop.Automation do
@moduledoc false
use GenServer
@ready_timeout 60_000
@request_timeout 30_000
def start_session(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
def stop_session(session) do
GenServer.stop(session, :normal, @request_timeout)
catch
:exit, _reason -> :ok
end
def snapshot(session) do
GenServer.call(session, :snapshot, @request_timeout)
end
def click(session, selector) when is_binary(selector) do
GenServer.call(session, {:click, selector}, @request_timeout)
end
def capture_screenshot(session, destination) when is_binary(destination) do
GenServer.call(session, {:capture_screenshot, destination}, @request_timeout)
end
def child_info(session) do
GenServer.call(session, :child_info, @request_timeout)
end
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
screenshot_dir = Keyword.get(opts, :screenshot_dir, System.tmp_dir!())
port = Keyword.get_lazy(opts, :port, &free_tcp_port/0)
project_root = project_root()
base_url = BDS.Desktop.url(port)
File.mkdir_p!(screenshot_dir)
ensure_http_client_started()
app_port = start_app_process(project_root, port)
:ok = wait_for_server(base_url)
driver_port = start_driver_process(project_root, base_url, screenshot_dir)
state = %{
app_port: app_port,
app_os_pid: port_os_pid(app_port),
driver_port: driver_port,
driver_os_pid: port_os_pid(driver_port),
driver_buffer: "",
base_url: base_url,
screenshot_dir: screenshot_dir
}
{reply, state} = await_driver_ready(state)
case reply do
:ok -> {:ok, state}
{:error, reason} -> {:stop, reason}
end
end
@impl true
def handle_call(:snapshot, _from, state) do
{reply, state} = driver_request(state, %{"command" => "snapshot"})
{:reply, atomize_map(reply), state}
end
def handle_call({:click, selector}, _from, state) do
{reply, state} = driver_request(state, %{"command" => "click", "selector" => selector})
{:reply, normalize_simple_reply(reply), state}
end
def handle_call({:capture_screenshot, destination}, _from, state) do
File.mkdir_p!(Path.dirname(destination))
{reply, state} =
driver_request(state, %{"command" => "screenshot", "path" => destination})
{:reply, reply, state}
end
def handle_call(:child_info, _from, state) do
{:reply, %{app_os_pid: state.app_os_pid, driver_os_pid: state.driver_os_pid}, state}
end
@impl true
def terminate(_reason, state) do
shutdown_driver(state)
shutdown_app(state)
:ok
end
defp start_app_process(project_root, port) do
executable = System.find_executable("elixir") || raise "missing elixir executable"
Port.open(
{:spawn_executable, executable},
[
:binary,
:stderr_to_stdout,
:exit_status,
:use_stdio,
:hide,
{:cd, project_root},
{:env,
[
{~c"MIX_ENV", ~c"test"},
{~c"BDS_DESKTOP_AUTOMATION", ~c"1"},
{~c"BDS_DESKTOP_PORT", String.to_charlist(Integer.to_string(port))}
]},
{:args,
[
"-S",
"mix",
"run",
"scripts/desktop_automation_app.exs"
]}
]
)
end
defp start_driver_process(project_root, base_url, screenshot_dir) do
executable = System.find_executable("node") || raise "missing node executable"
Port.open(
{:spawn_executable, executable},
[
:binary,
:stderr_to_stdout,
:exit_status,
:use_stdio,
:hide,
{:cd, project_root},
{:args,
[
Path.join([project_root, "scripts", "desktop_automation_runner.mjs"]),
base_url,
screenshot_dir
]}
]
)
end
defp await_driver_ready(state) do
receive_driver_message(state, @ready_timeout, fn message ->
case message do
%{"status" => "ready"} -> {:ok, :ok}
%{"status" => "error", "message" => reason} -> {:ok, {:error, reason}}
_other -> :continue
end
end)
end
defp driver_request(state, payload) do
ref = Integer.to_string(System.unique_integer([:positive, :monotonic]))
request = Map.put(payload, "ref", ref)
Port.command(state.driver_port, Jason.encode!(request) <> "\n")
receive_driver_message(state, @request_timeout, fn message ->
case message do
%{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result}
%{"ref" => ^ref, "status" => "error", "message" => reason} ->
raise "desktop automation request failed: #{reason}"
_other ->
:continue
end
end)
end
defp safe_driver_request(state, payload) do
driver_request(state, payload)
rescue
_error -> :ok
end
defp shutdown_driver(state) do
_ = safe_driver_request(state, %{"command" => "close"})
await_port_exit(state[:driver_port], 5_000)
safe_close_port(state[:driver_port])
end
defp shutdown_app(state) do
if state[:app_port] do
Port.command(state.app_port, "stop\n")
await_port_exit(state.app_port, 10_000)
safe_close_port(state.app_port)
end
end
defp receive_driver_message(state, timeout, matcher) do
deadline = System.monotonic_time(:millisecond) + timeout
process_driver_messages(state, deadline, matcher)
end
defp process_driver_messages(state, deadline, matcher) do
{messages, buffer} = split_driver_buffer(state.driver_buffer)
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} ->
decoded = Jason.decode!(message)
case matcher.(decoded) do
{:ok, reply} -> {:halt, {acc, reply}}
:continue -> {:cont, {acc, nil}}
end
end) do
{state, nil} ->
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
receive do
{port, {:data, data}} when port == state.driver_port ->
process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher)
{port, {:exit_status, status}} when port == state.driver_port ->
raise "desktop automation driver exited with status #{status}"
{port, {:exit_status, status}} when port == state.app_port ->
raise "desktop app process exited with status #{status}"
{_port, {:data, _data}} ->
process_driver_messages(state, deadline, matcher)
after
remaining -> raise "desktop automation timed out waiting for driver response"
end
{state, reply} ->
{reply, state}
end
end
defp split_driver_buffer(buffer) do
case String.split(buffer, "\n") do
[] -> {[], ""}
[single] -> {[], single}
parts -> {Enum.drop(parts, -1), List.last(parts)}
end
end
defp wait_for_server(base_url) do
deadline = System.monotonic_time(:millisecond) + @ready_timeout
do_wait_for_server(base_url, deadline)
end
defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
_other ->
if System.monotonic_time(:millisecond) >= deadline do
raise "desktop app process did not become healthy in time"
else
Process.sleep(200)
do_wait_for_server(base_url, deadline)
end
end
end
defp free_tcp_port do
{:ok, socket} = :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true])
{:ok, port} = :inet.port(socket)
:gen_tcp.close(socket)
port
end
defp ensure_http_client_started do
_ = Application.ensure_all_started(:inets)
_ = Application.ensure_all_started(:ssl)
:ok
end
defp await_port_exit(nil, _timeout), do: :ok
defp await_port_exit(port, timeout) do
receive do
{^port, {:exit_status, _status}} -> :ok
{^port, {:data, _data}} -> await_port_exit(port, timeout)
after
timeout -> :ok
end
end
defp port_os_pid(nil), do: nil
defp port_os_pid(port) do
case Port.info(port, :os_pid) do
{:os_pid, pid} when is_integer(pid) -> pid
_other -> nil
end
end
defp safe_close_port(nil), do: :ok
defp safe_close_port(port) do
Port.close(port)
catch
:error, _reason -> :ok
end
defp normalize_simple_reply("ok"), do: :ok
defp normalize_simple_reply(reply), do: reply
defp atomize_map(map) when is_map(map) do
Enum.into(map, %{}, fn {key, value} ->
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
normalized_value = if is_map(value), do: atomize_map(value), else: value
{normalized_key, normalized_value}
end)
end
defp project_root do
Path.expand("../../..", __DIR__)
end
end

View File

@@ -11,7 +11,7 @@ defmodule BDS.Desktop.Router do
signing_salt: "desktop-shell"
plug :match
plug Desktop.Auth
plug :maybe_require_desktop_auth
plug Plug.Static,
at: "/assets",
@@ -46,4 +46,12 @@ defmodule BDS.Desktop.Router do
Application.get_env(:bds, :desktop)[:secret_key_base] ||
raise "missing :desktop secret_key_base configuration"
end
defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
conn
else
Desktop.Auth.call(conn, [])
end
end
end

View File

@@ -19,7 +19,10 @@ defmodule BDS.Desktop.Server do
end
def port do
Application.get_env(:bds, :desktop)[:port] || 4010
case System.get_env("BDS_DESKTOP_PORT") do
value when is_binary(value) -> String.to_integer(value)
_other -> Application.get_env(:bds, :desktop)[:port] || 4010
end
end
@impl true

View File

@@ -2,6 +2,6 @@ defmodule BDS.Desktop.ShellController do
@moduledoc false
def index_html do
File.read!(Application.app_dir(:bds, ["priv", "ui", "index.html"]))
BDS.UI.ShellPage.render()
end
end

View File

@@ -7,26 +7,7 @@ defmodule BDS.UI.ShellPage do
alias BDS.UI.Workbench
def render do
bootstrap =
%{
registry: %{
sidebar_views: Registry.sidebar_views(),
editor_routes: Registry.editor_routes(),
default_sidebar_view: Registry.default_sidebar_view()
},
menu_groups: MenuBar.default_groups(),
session: Session.serialize(Workbench.new(panel_visible: true)),
status: %{
post_count: 12,
media_count: 34,
theme_badge: "zinc",
ui_language: "en",
offline_mode: true,
running_task_message: "Building starter shell",
running_task_overflow: 1,
git_badge_count: 7
}
}
bootstrap = bootstrap()
[
"<!DOCTYPE html>",
@@ -34,29 +15,215 @@ defmodule BDS.UI.ShellPage do
"<head>",
" <meta charset=\"utf-8\">",
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
" <title>bDS Shell</title>",
" <link rel=\"stylesheet\" href=\"./app.css\">",
" <title>Blogging Desktop Server</title>",
" <link rel=\"stylesheet\" href=\"/assets/app.css\">",
"</head>",
"<body>",
" <div id=\"bds-shell-app\">",
" <header data-region=\"title-bar\"></header>",
" <div data-region=\"activity-bar\"></div>",
" <aside data-region=\"sidebar\"></aside>",
" <div data-role=\"resize-handle\" data-target=\"sidebar\"></div>",
" <main data-region=\"content\">",
" <div data-region=\"tab-bar\"></div>",
" <section data-region=\"editor\"></section>",
" <section data-region=\"panel\"></section>",
" </main>",
" <div data-role=\"resize-handle\" data-target=\"assistant\"></div>",
" <aside data-region=\"assistant-sidebar\"></aside>",
" <footer data-region=\"status-bar\"></footer>",
" <div class=\"app\" id=\"bds-shell-app\">",
" <div class=\"window-titlebar\" data-region=\"title-bar\"></div>",
" <div class=\"app-main\">",
" <aside class=\"activity-bar\" data-region=\"activity-bar\"></aside>",
" <section class=\"sidebar-shell\" data-testid=\"sidebar-shell\">",
" <div class=\"sidebar\" data-region=\"sidebar\"></div>",
" <div class=\"resizable-panel-divider sidebar-divider\" data-resize=\"sidebar\" data-role=\"resize-handle\"></div>",
" </section>",
" <main class=\"app-content\" data-region=\"content\">",
" <div class=\"tab-bar\" data-region=\"tab-bar\"></div>",
" <section class=\"editor-shell\" data-region=\"editor\"></section>",
" <section class=\"panel-shell\" data-region=\"panel\"></section>",
" </main>",
" <section class=\"assistant-sidebar-shell\" data-testid=\"assistant-shell\">",
" <div class=\"resizable-panel-divider assistant-divider\" data-resize=\"assistant\" data-role=\"resize-handle\"></div>",
" <aside class=\"assistant-sidebar\" data-region=\"assistant-sidebar\"></aside>",
" </section>",
" </div>",
" <footer class=\"status-bar\" data-region=\"status-bar\"></footer>",
" </div>",
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
" <script src=\"./app.js\"></script>",
" <script src=\"/assets/app.js\"></script>",
"</body>",
"</html>"
]
|> Enum.join("\n")
end
defp bootstrap do
workbench = Workbench.new(panel_visible: true, assistant_sidebar_visible: true)
%{
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
registry: %{
sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1),
editor_routes: Enum.map(Registry.editor_routes(), &encode_editor_route/1),
default_sidebar_view: Atom.to_string(Registry.default_sidebar_view())
},
menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1),
session: Session.serialize(workbench),
content: %{
sidebar: sidebar_content(),
dashboard: dashboard_content(),
assistant_cards: assistant_cards(),
editor_meta: editor_meta()
},
status:
Workbench.status_bar(workbench,
post_count: 42,
media_count: 18,
theme_badge: "desktop-shell",
ui_language: "en",
offline_mode: true,
running_task_message: "Desktop shell ready",
running_task_overflow: 0,
git_badge_count: 3
)
}
end
defp encode_sidebar_view(view) do
%{
id: Atom.to_string(view.id),
label: normalize_view_label(view.id, view.label),
activity_group: Atom.to_string(view.activity_group),
editor_route: Atom.to_string(view.editor_route),
entity_tab: Map.get(view, :entity_tab, false),
singleton: Map.get(view, :singleton, false)
}
end
defp encode_editor_route(route) do
%{
id: Atom.to_string(route.id),
singleton: route.singleton,
entity_tab: route.entity_tab,
title: route.title
}
end
defp encode_menu_group(group) do
%{
id: Atom.to_string(group.id),
label: humanize(group.id),
items:
Enum.map(group.items, fn item ->
%{id: Atom.to_string(item.id), label: humanize(item.id)}
end)
}
end
defp sidebar_content do
%{
"posts" => %{
title: "Posts",
subtitle: "Drafts, published entries, and archive history",
sections: [
%{
title: "Drafts",
items: [
%{id: "post-welcome", title: "Welcome to bDS2", meta: "Updated today", badge: "draft", route: "post"},
%{id: "post-launch-plan", title: "Launch plan", meta: "Updated yesterday", badge: "draft", route: "post"}
]
},
%{
title: "Published",
items: [
%{id: "post-roadmap", title: "Roadmap", meta: "Published Feb 10, 2026", badge: "2 langs", route: "post"}
]
},
%{
title: "Archived",
items: [
%{id: "post-retrospective", title: "Retrospective", meta: "Archived Jan 12, 2026", badge: "archive", route: "post"}
]
}
]
},
"pages" => simple_list_view("Pages", "Standalone pages", [
%{id: "page-about", title: "About", meta: "Static page", route: "post"},
%{id: "page-contact", title: "Contact", meta: "Static page", route: "post"}
]),
"media" => simple_list_view("Media", "Images and files", [
%{id: "media-hero", title: "hero-shot.jpg", meta: "Image asset", route: "media"},
%{id: "media-banner", title: "launch-banner.png", meta: "Image asset", route: "media"}
]),
"scripts" => simple_list_view("Scripts", "Automation helpers", [
%{id: "script-import", title: "Import posts", meta: "Lua utility", route: "scripts"},
%{id: "script-sync", title: "Sync tags", meta: "Lua utility", route: "scripts"}
]),
"templates" => simple_list_view("Templates", "Site rendering", [
%{id: "template-post", title: "post.liquid", meta: "Post template", route: "templates"},
%{id: "template-list", title: "list.liquid", meta: "List template", route: "templates"}
]),
"tags" => simple_list_view("Tags", "Tag management", [
%{id: "tag-launch", title: "launch", meta: "12 posts", route: "tags"},
%{id: "tag-writing", title: "writing", meta: "7 posts", route: "tags"}
]),
"chat" => simple_list_view("Chat", "AI conversations", [
%{id: "chat-planning", title: "Planning session", meta: "Offline gated", route: "chat"},
%{id: "chat-translation", title: "Translation QA", meta: "Offline gated", route: "chat"}
]),
"import" => simple_list_view("Import", "Import definitions", [
%{id: "import-wordpress", title: "WordPress import", meta: "Ready", route: "import"}
]),
"git" => simple_list_view("Git", "Working tree and history", [
%{id: "git-working-tree", title: "Working tree", meta: "3 changed files", route: "git_diff"}
]),
"settings" => simple_list_view("Settings", "Project and publishing", [
%{id: "settings-project", title: "Project", meta: "Paths and defaults", route: "settings"},
%{id: "settings-ai", title: "AI", meta: "Offline controls", route: "settings"}
])
}
end
defp simple_list_view(title, subtitle, items) do
%{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]}
end
defp dashboard_content do
%{
title: "Dashboard",
subtitle: "Desktop workbench shell wired through Elixir",
summary_cards: [
%{label: "Posts", value: "42", detail: "Across draft, published, and archive"},
%{label: "Media", value: "18", detail: "Images and documents indexed"},
%{label: "Tasks", value: "1", detail: "One background action visible in the status bar"}
],
checklist: [
"Native menu groups mirror the old application shell",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions",
"Automation can boot the shell in a separate process and capture screenshots"
]
}
end
defp assistant_cards do
[
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
%{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
%{label: "Desktop Runtime", text: "The app window is now served from the Elixir shell renderer."}
]
end
defp editor_meta do
%{
dashboard: [
%{label: "Status", value: "Workbench shell ready"},
%{label: "Mode", value: "Offline"},
%{label: "Main Language", value: "en"}
]
}
end
defp normalize_view_label(:chat, _label), do: "Chat"
defp normalize_view_label(:git, _label), do: "Git"
defp normalize_view_label(_id, label), do: label
defp humanize(value) when is_atom(value), do: value |> Atom.to_string() |> humanize()
defp humanize(value) when is_binary(value) do
value
|> String.replace("_", " ")
|> String.split(" ")
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
end

60
package-lock.json generated Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "bds-ui-automation",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bds-ui-automation",
"devDependencies": {
"playwright": "^1.54.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

10
package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "bds-ui-automation",
"private": true,
"devDependencies": {
"playwright": "^1.54.1"
},
"scripts": {
"ui:install": "playwright install chromium"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,104 +1,137 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bDS Shell</title>
<link rel="stylesheet" href="./app.css">
</head>
<body>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Blogging Desktop Server</title>
<link rel="stylesheet" href="/assets/app.css" />
</head>
<body>
<div class="app" id="app">
<div class="window-titlebar">
<div class="window-titlebar-menu-bar"></div>
<div class="window-titlebar-title">Blogging Desktop Server</div>
<div class="window-titlebar-actions">
<button class="window-titlebar-action-button" data-command="toggle-sidebar" aria-label="Toggle sidebar">
<span class="window-titlebar-sidebar-icon"><span class="window-titlebar-sidebar-pane"></span></span>
</button>
<button class="window-titlebar-action-button" data-command="toggle-panel" aria-label="Toggle panel">
<span class="window-titlebar-panel-icon"></span>
</button>
</div>
</div>
<div class="app-main">
<aside class="activity-bar"></aside>
<section class="sidebar-shell">
<div class="sidebar"></div>
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar"></div>
</section>
<main class="app-content">
<div class="tab-bar"></div>
<div class="editor-shell"></div>
<div class="panel-shell"></div>
</main>
<section class="assistant-sidebar-shell">
<div class="resizable-panel-divider assistant-divider" data-resize="assistant"></div>
<div class="assistant-sidebar"></div>
</section>
</div>
<div class="status-bar"></div>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Blogging Desktop Server</title>
<link rel="stylesheet" href="./app.css" />
</head>
<body>
<div class="app" id="bds-shell-app">
<div class="window-titlebar" data-region="title-bar"></div>
<div class="app-main">
<aside class="activity-bar" data-region="activity-bar"></aside>
<section class="sidebar-shell" data-testid="sidebar-shell">
<div class="sidebar" data-region="sidebar"></div>
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
</section>
<main class="app-content" data-region="content">
<div class="tab-bar" data-region="tab-bar"></div>
<section class="editor-shell" data-region="editor"></section>
<section class="panel-shell" data-region="panel"></section>
</main>
<section class="assistant-sidebar-shell" data-testid="assistant-shell">
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
<aside class="assistant-sidebar" data-region="assistant-sidebar"></aside>
</section>
</div>
<footer class="status-bar" data-region="status-bar"></footer>
</div>
<script id="bds-bootstrap" type="application/json">
{
"menuGroups": [
{"id":"app","label":"App","items":[{"id":"about","label":"About"}]},
{"id":"file","label":"File","items":[{"id":"new_post","label":"New Post"},{"id":"close_tab","label":"Close Tab"}]},
{"id":"edit","label":"Edit","items":[{"id":"undo","label":"Undo"},{"id":"redo","label":"Redo"}]},
{"id":"view","label":"View","items":[{"id":"toggle_sidebar","label":"Toggle Sidebar"},{"id":"toggle_panel","label":"Toggle Panel"},{"id":"toggle_assistant_sidebar","label":"Toggle Assistant Sidebar"}]},
{"id":"window","label":"Window","items":[{"id":"minimize","label":"Minimize"}]},
{"id":"help","label":"Help","items":[{"id":"documentation","label":"Documentation"}]}
<script id="bds-shell-bootstrap" type="application/json">
{
"title": "Blogging Desktop Server",
"registry": {
"default_sidebar_view": "posts",
"sidebar_views": [
{ "id": "posts", "label": "Posts", "activity_group": "top", "editor_route": "post", "entity_tab": true, "singleton": false },
{ "id": "media", "label": "Media", "activity_group": "top", "editor_route": "media", "entity_tab": true, "singleton": false },
{ "id": "settings", "label": "Settings", "activity_group": "bottom", "editor_route": "settings", "entity_tab": false, "singleton": true }
],
"sidebarViews": [
{"id":"posts","label":"Posts","group":"top","items":["welcome.md","launch-plan.md","publishing-notes.md"]},
{"id":"pages","label":"Pages","group":"top","items":["about.md","contact.md"]},
{"id":"media","label":"Media","group":"top","items":["cover.jpg","launch-banner.png"]},
{"id":"scripts","label":"Scripts","group":"top","items":["import_posts.exs","sync_tags.exs"]},
{"id":"templates","label":"Templates","group":"top","items":["post.liquid","listing.liquid"]},
{"id":"git","label":"Git","group":"bottom","items":["Working tree clean"]},
{"id":"settings","label":"Settings","group":"bottom","items":["Project", "Publishing", "AI"]}
],
"tabs": [
{"id":"dashboard","title":"Dashboard","kind":"dashboard","pinned":true,"dirty":false},
{"id":"post:welcome","title":"welcome.md","kind":"post","pinned":true,"dirty":true},
{"id":"post:launch","title":"launch-plan.md","kind":"post","pinned":false,"dirty":false}
],
"activeTabId": "post:welcome",
"activeView": "posts",
"sidebarVisible": true,
"sidebarWidth": 320,
"assistantVisible": true,
"assistantWidth": 336,
"panelVisible": true,
"panelTab": "problems",
"statusBar": {
"left": [
{"id":"branch","label":"main"},
{"id":"sync","label":"Filesystem synced"},
{"id":"language","label":"EN"}
"editor_routes": [
{ "id": "dashboard", "title": "Dashboard", "singleton": true, "entity_tab": false },
{ "id": "post", "title": "Post", "singleton": false, "entity_tab": true },
{ "id": "media", "title": "Media", "singleton": false, "entity_tab": true },
{ "id": "settings", "title": "Settings", "singleton": true, "entity_tab": false }
]
},
"menu_groups": [
{ "id": "app", "label": "App", "items": [{ "id": "about", "label": "About" }] },
{ "id": "view", "label": "View", "items": [{ "id": "toggle_sidebar", "label": "Toggle Sidebar" }] }
],
"session": {
"sidebar_visible": true,
"sidebar_width": 280,
"active_view": "posts",
"assistant_sidebar_visible": true,
"assistant_sidebar_width": 320,
"panel": { "visible": true, "active_tab": "tasks" },
"tabs": [],
"active_tab": null,
"dirty_tabs": []
},
"content": {
"sidebar": {
"posts": {
"title": "Posts",
"subtitle": "Drafts and publishing",
"sections": [
{
"title": "Drafts",
"items": [
{ "id": "post-welcome", "title": "Welcome to bDS2", "meta": "Updated today", "badge": "draft", "route": "post" }
]
}
]
},
"media": {
"title": "Media",
"subtitle": "Images and files",
"sections": [
{
"title": "Media",
"items": [
{ "id": "media-hero", "title": "hero-shot.jpg", "meta": "Image asset", "route": "media" }
]
}
]
},
"settings": {
"title": "Settings",
"subtitle": "Project preferences",
"sections": [
{
"title": "Settings",
"items": [
{ "id": "settings", "title": "Project", "meta": "Defaults and paths", "route": "settings" }
]
}
]
}
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Static shell bundle for direct inspection",
"summary_cards": [
{ "label": "Posts", "value": "42", "detail": "Drafts, published, archive" }
],
"right": [
{"id":"project","label":"Starter project"},
{"id":"mode","label":"Airplane off"},
{"id":"theme","label":"Desktop shell"}
"checklist": [
"Static bundle is valid HTML",
"Shell assets render without duplicated bootstrap code"
]
},
"assistant_cards": [
{ "label": "Desktop Runtime", "text": "Static bundle mirrors the desktop shell layout." }
],
"editor_meta": {
"dashboard": [
{ "label": "Status", "value": "Ready" }
]
}
},
"status": {
"left": { "running_task_message": "Static preview", "running_task_overflow": 0 },
"right": {
"post_count": "42 posts",
"media_count": "18 media",
"theme_badge": "desktop-shell",
"offline_mode": true,
"ui_language": "en",
"brand": "bDS"
}
}
</script>
<script src="/assets/app.js"></script>
</body>
</html>
}
</script>
<script src="./app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
Enum.reduce_while(IO.stream(:stdio, :line), :ok, fn line, _acc ->
if String.trim(line) == "stop" do
{:halt, :ok}
else
{:cont, :ok}
end
end)

View File

@@ -0,0 +1,80 @@
import { chromium } from "playwright";
import readline from "node:readline";
const [url, screenshotDir] = process.argv.slice(2);
if (!url) {
console.log(JSON.stringify({ status: "error", message: "missing automation url" }));
process.exit(1);
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await page.goto(url, { waitUntil: "networkidle" });
await page.locator("#bds-shell-app").waitFor({ state: "visible" });
await page.emulateMedia({ reducedMotion: "reduce" });
console.log(JSON.stringify({ status: "ready", screenshotDir }));
} catch (error) {
console.log(JSON.stringify({ status: "error", message: error.message }));
await browser.close();
process.exit(1);
}
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) {
continue;
}
const message = JSON.parse(line);
const ref = message.ref;
try {
if (message.command === "snapshot") {
const result = await page.evaluate(() => {
const text = (selector) => document.querySelector(selector)?.textContent?.trim() ?? null;
const texts = (selector, mapper) => Array.from(document.querySelectorAll(selector)).map(mapper);
const hasClass = (selector, className) => document.querySelector(selector)?.classList.contains(className) ?? false;
return {
window_title: text("[data-testid='window-title']"),
active_view: document.querySelector("[data-testid='activity-button'][data-active='true']")?.dataset.view ?? null,
sidebar_visible: !hasClass("[data-testid='sidebar-shell']", "is-hidden"),
editor_title: text("[data-testid='editor-title']"),
activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")),
sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()),
editor_meta_labels: texts("[data-testid='editor-meta-label']", (node) => node.textContent.trim())
};
});
console.log(JSON.stringify({ ref, status: "ok", result }));
continue;
}
if (message.command === "click") {
await page.locator(message.selector).click();
await page.waitForTimeout(50);
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
continue;
}
if (message.command === "screenshot") {
await page.screenshot({ path: message.path, fullPage: false });
console.log(JSON.stringify({ ref, status: "ok", result: message.path }));
continue;
}
if (message.command === "close") {
await browser.close();
console.log(JSON.stringify({ ref, status: "ok", result: "closed" }));
process.exit(0);
}
console.log(JSON.stringify({ ref, status: "error", message: `unknown command: ${message.command}` }));
} catch (error) {
console.log(JSON.stringify({ ref, status: "error", message: error.message }));
}
}

View File

@@ -0,0 +1,107 @@
defmodule BDS.Desktop.AutomationTest do
use ExUnit.Case, async: false
alias BDS.Desktop.Automation
@tag timeout: 120_000
test "automation boots the app in a separate process, inspects shell content, drives UI, and captures screenshots" do
screenshot_dir = Path.join(System.tmp_dir!(), "bds-desktop-automation-test")
File.rm_rf!(screenshot_dir)
File.mkdir_p!(screenshot_dir)
{:ok, session} = Automation.start_session(screenshot_dir: screenshot_dir)
on_exit(fn ->
Automation.stop_session(session)
File.rm_rf!(screenshot_dir)
end)
snapshot = Automation.snapshot(session)
assert snapshot.window_title == "Blogging Desktop Server"
assert snapshot.active_view == "posts"
assert snapshot.sidebar_visible == true
assert snapshot.editor_title == "Dashboard"
assert snapshot.activity_labels == [
"Posts",
"Pages",
"Media",
"Scripts",
"Templates",
"Tags",
"Chat",
"Import",
"Git",
"Settings"
]
assert "Drafts" in snapshot.sidebar_sections
assert "Status" in snapshot.editor_meta_labels
assert :ok = Automation.click(session, "[data-testid='toggle-sidebar']")
snapshot = Automation.snapshot(session)
assert snapshot.sidebar_visible == false
screenshot_path = Path.join(screenshot_dir, "main-window.png")
assert Automation.capture_screenshot(session, screenshot_path) == screenshot_path
assert File.exists?(screenshot_path)
end
@tag timeout: 120_000
test "automation stop_session shuts down the app and browser child processes it started" do
baseline = automation_process_counts()
{:ok, session} = Automation.start_session()
children = Automation.child_info(session)
assert is_integer(children.app_os_pid)
assert is_integer(children.driver_os_pid)
assert os_pid_alive?(children.app_os_pid)
assert os_pid_alive?(children.driver_os_pid)
assert automation_process_counts().app == baseline.app + 1
assert automation_process_counts().driver == baseline.driver + 1
assert :ok = Automation.stop_session(session)
assert wait_until(fn -> not os_pid_alive?(children.app_os_pid) end)
assert wait_until(fn -> not os_pid_alive?(children.driver_os_pid) end)
assert automation_process_counts() == baseline
end
defp os_pid_alive?(pid) do
case System.cmd("kill", ["-0", Integer.to_string(pid)], stderr_to_stdout: true) do
{_, 0} -> true
_other -> false
end
end
defp automation_process_counts do
%{app: count_processes("scripts/desktop_automation_app\\.exs"), driver: count_processes("desktop_automation_runner\\.mjs")}
end
defp count_processes(pattern) do
{output, 0} =
System.cmd("sh", ["-c", "ps -Ao args | rg -c '#{pattern}' || true"], stderr_to_stdout: true)
output
|> String.trim()
|> case do
"" -> 0
value -> String.to_integer(value)
end
end
defp wait_until(fun, timeout \\ 5_000)
defp wait_until(fun, timeout) when timeout <= 0, do: fun.()
defp wait_until(fun, timeout) do
if fun.() do
true
else
Process.sleep(100)
wait_until(fun, timeout - 100)
end
end
end

View File

@@ -90,15 +90,16 @@ defmodule BDS.UI.ShellTest do
test "shell page renders the inspectable base app with bootstrap data and shell controls" do
html = ShellPage.render()
assert html =~ ~s(<div id="bds-shell-app")
assert html =~ ~s(<div class="app" id="bds-shell-app")
assert html =~ ~s(data-region="activity-bar")
assert html =~ ~s(data-region="sidebar")
assert html =~ ~s(data-region="editor")
assert html =~ ~s(data-region="status-bar")
assert html =~ ~s(data-role="resize-handle")
assert html =~ ~s(id="bds-shell-bootstrap")
assert html =~ ~s(src="./app.js")
assert html =~ ~s(href="./app.css")
assert html =~ ~s(src="/assets/app.js")
assert html =~ ~s(href="/assets/app.css")
assert html =~ ~s(Desktop shell ready)
end
test "static shell bundle exists for direct browser inspection" do