1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
/doc/
|
||||
/.elixir_ls/
|
||||
/erl_crash.dump
|
||||
/node_modules/
|
||||
/priv/data/*.db
|
||||
/priv/data/*.db-shm
|
||||
/priv/data/*.db-wal
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
320
lib/bds/desktop/automation.ex
Normal file
320
lib/bds/desktop/automation.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
60
package-lock.json
generated
Normal 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
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "bds-ui-automation",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"playwright": "^1.54.1"
|
||||
},
|
||||
"scripts": {
|
||||
"ui:install": "playwright install chromium"
|
||||
}
|
||||
}
|
||||
1124
priv/ui/app.css
1124
priv/ui/app.css
File diff suppressed because it is too large
Load Diff
883
priv/ui/app.js
883
priv/ui/app.js
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
7
scripts/desktop_automation_app.exs
Normal file
7
scripts/desktop_automation_app.exs
Normal 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)
|
||||
80
scripts/desktop_automation_runner.mjs
Normal file
80
scripts/desktop_automation_runner.mjs
Normal 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 }));
|
||||
}
|
||||
}
|
||||
107
test/bds/desktop/automation_test.exs
Normal file
107
test/bds/desktop/automation_test.exs
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user