1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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"
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1126
priv/ui/app.css
1126
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>
|
<!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>
|
||||||
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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user