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/ /doc/
/.elixir_ls/ /.elixir_ls/
/erl_crash.dump /erl_crash.dump
/node_modules/
/priv/data/*.db /priv/data/*.db
/priv/data/*.db-shm /priv/data/*.db-shm
/priv/data/*.db-wal /priv/data/*.db-wal

View File

@@ -5,24 +5,17 @@ defmodule BDS.Application do
def desktop_children(env \\ nil) 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 def desktop_children(_env) do
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
[ [{BDS.Desktop.Server, []} | desktop_window_children()]
{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
]}
]
else else
[] []
end end
@@ -50,4 +43,28 @@ defmodule BDS.Application do
Application.get_env(:bds, :current_env_override) || Application.get_env(:bds, :current_env_override) ||
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod) if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
end 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 end

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop do
@moduledoc false @moduledoc false
def url do def url do
Application.get_env(:bds, :desktop)[:port] BDS.Desktop.Server.port()
|> url() |> url()
end 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" signing_salt: "desktop-shell"
plug :match plug :match
plug Desktop.Auth plug :maybe_require_desktop_auth
plug Plug.Static, plug Plug.Static,
at: "/assets", at: "/assets",
@@ -46,4 +46,12 @@ defmodule BDS.Desktop.Router do
Application.get_env(:bds, :desktop)[:secret_key_base] || Application.get_env(:bds, :desktop)[:secret_key_base] ||
raise "missing :desktop secret_key_base configuration" raise "missing :desktop secret_key_base configuration"
end 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 end

View File

@@ -19,7 +19,10 @@ defmodule BDS.Desktop.Server do
end end
def port do 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 end
@impl true @impl true

View File

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

View File

@@ -7,26 +7,7 @@ defmodule BDS.UI.ShellPage do
alias BDS.UI.Workbench alias BDS.UI.Workbench
def render do def render do
bootstrap = bootstrap = 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
}
}
[ [
"<!DOCTYPE html>", "<!DOCTYPE html>",
@@ -34,29 +15,215 @@ defmodule BDS.UI.ShellPage do
"<head>", "<head>",
" <meta charset=\"utf-8\">", " <meta charset=\"utf-8\">",
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">", " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
" <title>bDS Shell</title>", " <title>Blogging Desktop Server</title>",
" <link rel=\"stylesheet\" href=\"./app.css\">", " <link rel=\"stylesheet\" href=\"/assets/app.css\">",
"</head>", "</head>",
"<body>", "<body>",
" <div id=\"bds-shell-app\">", " <div class=\"app\" id=\"bds-shell-app\">",
" <header data-region=\"title-bar\"></header>", " <div class=\"window-titlebar\" data-region=\"title-bar\"></div>",
" <div data-region=\"activity-bar\"></div>", " <div class=\"app-main\">",
" <aside data-region=\"sidebar\"></aside>", " <aside class=\"activity-bar\" data-region=\"activity-bar\"></aside>",
" <div data-role=\"resize-handle\" data-target=\"sidebar\"></div>", " <section class=\"sidebar-shell\" data-testid=\"sidebar-shell\">",
" <main data-region=\"content\">", " <div class=\"sidebar\" data-region=\"sidebar\"></div>",
" <div data-region=\"tab-bar\"></div>", " <div class=\"resizable-panel-divider sidebar-divider\" data-resize=\"sidebar\" data-role=\"resize-handle\"></div>",
" <section data-region=\"editor\"></section>", " </section>",
" <section data-region=\"panel\"></section>", " <main class=\"app-content\" data-region=\"content\">",
" </main>", " <div class=\"tab-bar\" data-region=\"tab-bar\"></div>",
" <div data-role=\"resize-handle\" data-target=\"assistant\"></div>", " <section class=\"editor-shell\" data-region=\"editor\"></section>",
" <aside data-region=\"assistant-sidebar\"></aside>", " <section class=\"panel-shell\" data-region=\"panel\"></section>",
" <footer data-region=\"status-bar\"></footer>", " </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>", " </div>",
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>", " <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
" <script src=\"./app.js\"></script>", " <script src=\"/assets/app.js\"></script>",
"</body>", "</body>",
"</html>" "</html>"
] ]
|> Enum.join("\n") |> Enum.join("\n")
end 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 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>bDS Shell</title> <title>Blogging Desktop Server</title>
<link rel="stylesheet" href="./app.css"> <link rel="stylesheet" href="./app.css" />
</head> </head>
<body> <body>
<!doctype html> <div class="app" id="bds-shell-app">
<html lang="en"> <div class="window-titlebar" data-region="title-bar"></div>
<head> <div class="app-main">
<meta charset="utf-8" /> <aside class="activity-bar" data-region="activity-bar"></aside>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <section class="sidebar-shell" data-testid="sidebar-shell">
<title>Blogging Desktop Server</title> <div class="sidebar" data-region="sidebar"></div>
<link rel="stylesheet" href="/assets/app.css" /> <div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
</head> </section>
<body> <main class="app-content" data-region="content">
<div class="app" id="app"> <div class="tab-bar" data-region="tab-bar"></div>
<div class="window-titlebar"> <section class="editor-shell" data-region="editor"></section>
<div class="window-titlebar-menu-bar"></div> <section class="panel-shell" data-region="panel"></section>
<div class="window-titlebar-title">Blogging Desktop Server</div> </main>
<div class="window-titlebar-actions"> <section class="assistant-sidebar-shell" data-testid="assistant-shell">
<button class="window-titlebar-action-button" data-command="toggle-sidebar" aria-label="Toggle sidebar"> <div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
<span class="window-titlebar-sidebar-icon"><span class="window-titlebar-sidebar-pane"></span></span> <aside class="assistant-sidebar" data-region="assistant-sidebar"></aside>
</button> </section>
<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>
</div> </div>
<footer class="status-bar" data-region="status-bar"></footer>
</div>
<script id="bds-bootstrap" type="application/json"> <script id="bds-shell-bootstrap" type="application/json">
{ {
"menuGroups": [ "title": "Blogging Desktop Server",
{"id":"app","label":"App","items":[{"id":"about","label":"About"}]}, "registry": {
{"id":"file","label":"File","items":[{"id":"new_post","label":"New Post"},{"id":"close_tab","label":"Close Tab"}]}, "default_sidebar_view": "posts",
{"id":"edit","label":"Edit","items":[{"id":"undo","label":"Undo"},{"id":"redo","label":"Redo"}]}, "sidebar_views": [
{"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": "posts", "label": "Posts", "activity_group": "top", "editor_route": "post", "entity_tab": true, "singleton": false },
{"id":"window","label":"Window","items":[{"id":"minimize","label":"Minimize"}]}, { "id": "media", "label": "Media", "activity_group": "top", "editor_route": "media", "entity_tab": true, "singleton": false },
{"id":"help","label":"Help","items":[{"id":"documentation","label":"Documentation"}]} { "id": "settings", "label": "Settings", "activity_group": "bottom", "editor_route": "settings", "entity_tab": false, "singleton": true }
], ],
"sidebarViews": [ "editor_routes": [
{"id":"posts","label":"Posts","group":"top","items":["welcome.md","launch-plan.md","publishing-notes.md"]}, { "id": "dashboard", "title": "Dashboard", "singleton": true, "entity_tab": false },
{"id":"pages","label":"Pages","group":"top","items":["about.md","contact.md"]}, { "id": "post", "title": "Post", "singleton": false, "entity_tab": true },
{"id":"media","label":"Media","group":"top","items":["cover.jpg","launch-banner.png"]}, { "id": "media", "title": "Media", "singleton": false, "entity_tab": true },
{"id":"scripts","label":"Scripts","group":"top","items":["import_posts.exs","sync_tags.exs"]}, { "id": "settings", "title": "Settings", "singleton": true, "entity_tab": false }
{"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"]} "menu_groups": [
], { "id": "app", "label": "App", "items": [{ "id": "about", "label": "About" }] },
"tabs": [ { "id": "view", "label": "View", "items": [{ "id": "toggle_sidebar", "label": "Toggle Sidebar" }] }
{"id":"dashboard","title":"Dashboard","kind":"dashboard","pinned":true,"dirty":false}, ],
{"id":"post:welcome","title":"welcome.md","kind":"post","pinned":true,"dirty":true}, "session": {
{"id":"post:launch","title":"launch-plan.md","kind":"post","pinned":false,"dirty":false} "sidebar_visible": true,
], "sidebar_width": 280,
"activeTabId": "post:welcome", "active_view": "posts",
"activeView": "posts", "assistant_sidebar_visible": true,
"sidebarVisible": true, "assistant_sidebar_width": 320,
"sidebarWidth": 320, "panel": { "visible": true, "active_tab": "tasks" },
"assistantVisible": true, "tabs": [],
"assistantWidth": 336, "active_tab": null,
"panelVisible": true, "dirty_tabs": []
"panelTab": "problems", },
"statusBar": { "content": {
"left": [ "sidebar": {
{"id":"branch","label":"main"}, "posts": {
{"id":"sync","label":"Filesystem synced"}, "title": "Posts",
{"id":"language","label":"EN"} "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": [ "checklist": [
{"id":"project","label":"Starter project"}, "Static bundle is valid HTML",
{"id":"mode","label":"Airplane off"}, "Shell assets render without duplicated bootstrap code"
{"id":"theme","label":"Desktop shell"} ]
},
"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> </script>
</body> <script src="./app.js"></script>
</html> </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 test "shell page renders the inspectable base app with bootstrap data and shell controls" do
html = ShellPage.render() 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="activity-bar")
assert html =~ ~s(data-region="sidebar") assert html =~ ~s(data-region="sidebar")
assert html =~ ~s(data-region="editor") assert html =~ ~s(data-region="editor")
assert html =~ ~s(data-region="status-bar") assert html =~ ~s(data-region="status-bar")
assert html =~ ~s(data-role="resize-handle") assert html =~ ~s(data-role="resize-handle")
assert html =~ ~s(id="bds-shell-bootstrap") assert html =~ ~s(id="bds-shell-bootstrap")
assert html =~ ~s(src="./app.js") assert html =~ ~s(src="/assets/app.js")
assert html =~ ~s(href="./app.css") assert html =~ ~s(href="/assets/app.css")
assert html =~ ~s(Desktop shell ready)
end end
test "static shell bundle exists for direct browser inspection" do test "static shell bundle exists for direct browser inspection" do