aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 11:18:56 -0400
committerChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 11:18:56 -0400
commit7e8472e1e2a0b39746ef86c041d56b92bab5a6b7 (patch)
tree4d37a36c945446b4bded6989a780abe4cd3f2f1e /test
parent46b72a375101b54819f8750750017bd71e7c3798 (diff)
Wire up cache, file watcher, and Unix socket daemon
Adds a gen_server cache holding the rendered menu, an fs/inotify-based watcher that debounces changes and invalidates it, a Unix domain socket serving the cached binary, and the escript client Openbox invokes (with an inline render fallback if the daemon isn't running). Also documents local deployment (systemd user unit, Openbox menu.xml wiring) in the README. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'test')
-rw-r--r--test/er_xdg_pipe_menu_cache_tests.erl130
-rw-r--r--test/er_xdg_pipe_menu_socket_tests.erl93
-rw-r--r--test/er_xdg_pipe_menu_watcher_tests.erl86
3 files changed, 309 insertions, 0 deletions
diff --git a/test/er_xdg_pipe_menu_cache_tests.erl b/test/er_xdg_pipe_menu_cache_tests.erl
new file mode 100644
index 0000000..a1d1440
--- /dev/null
+++ b/test/er_xdg_pipe_menu_cache_tests.erl
@@ -0,0 +1,130 @@
+-module(er_xdg_pipe_menu_cache_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+build_renders_entries_from_dir_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "foo.desktop", "Foo", "foo"),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"Foo">>) =/= nomatch).
+
+build_groups_by_category_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "foo.desktop", "Foo", "foo", "Utility"),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"label=\"Utility\"">>) =/= nomatch).
+
+build_skips_hidden_entries_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "visible.desktop", "Visible", "visible"),
+ ok = file:write_file(
+ filename:join(Dir, "hidden.desktop"),
+ <<"[Desktop Entry]\nName=Hidden\nExec=hidden\nNoDisplay=true\n">>
+ ),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"Visible">>) =/= nomatch),
+ ?assertEqual(nomatch, binary:match(Menu, <<"Hidden">>)).
+
+build_skips_unparseable_files_without_failing_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "good.desktop", "Good", "good"),
+ ok = file:write_file(filename:join(Dir, "bad.desktop"), <<"[Desktop Entry]\nExec=bad\n">>),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"Good">>) =/= nomatch).
+
+build_skips_missing_dirs_test() ->
+ ?assertEqual(
+ <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<openbox_pipe_menu>\n"
+ "</openbox_pipe_menu>\n">>,
+ er_xdg_pipe_menu_cache:build(["/does-not-exist"])
+ ).
+
+gen_server_serves_and_invalidates_test() ->
+ Root = mk_tmp_dir(),
+ AppDir = filename:join(Root, "applications"),
+ ok = file:make_dir(AppDir),
+ write_desktop(AppDir, "foo.desktop", "Foo", "foo"),
+ with_env(
+ [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}],
+ fun() ->
+ {ok, Pid} = er_xdg_pipe_menu_cache:start_link(),
+ Menu1 = er_xdg_pipe_menu_cache:get_menu(),
+ ?assert(binary:match(Menu1, <<"Foo">>) =/= nomatch),
+
+ write_desktop(AppDir, "bar.desktop", "Bar", "bar"),
+ ok = er_xdg_pipe_menu_cache:invalidate(),
+ Menu2 = er_xdg_pipe_menu_cache:get_menu(),
+ stop(Pid),
+ ?assert(binary:match(Menu2, <<"Bar">>) =/= nomatch)
+ end
+ ),
+ ok = rm_rf(Root).
+
+%% -- helpers ----------------------------------------------------------------
+
+%% start_link/0 links the started process to us (the test process);
+%% unlink before killing it so the kill signal doesn't propagate back
+%% and take the test process down with it.
+stop(Pid) ->
+ unlink(Pid),
+ true = erlang:exit(Pid, kill).
+
+with_env(Vars, Fun) ->
+ Saved = [{Name, os:getenv(Name)} || {Name, _} <- Vars],
+ try
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Vars),
+ Fun()
+ after
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Saved)
+ end.
+
+set_env(Name, false) -> os:unsetenv(Name);
+set_env(Name, Value) -> os:putenv(Name, Value).
+
+write_desktop(Dir, FileName, Name, Exec) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary(["[Desktop Entry]\nName=", Name, "\nExec=", Exec, "\n"])
+ ).
+
+write_desktop(Dir, FileName, Name, Exec, Categories) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary([
+ "[Desktop Entry]\nName=",
+ Name,
+ "\nExec=",
+ Exec,
+ "\nCategories=",
+ Categories,
+ ";\n"
+ ])
+ ).
+
+mk_tmp_dir() ->
+ Path = filename:join(
+ test_tmp_dir(), "eunit-cache-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ ok = file:make_dir(Path),
+ Path.
+
+test_tmp_dir() ->
+ case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ Dir -> Dir
+ end.
+
+rm_rf(Path) ->
+ case filelib:is_dir(Path) of
+ true ->
+ {ok, Entries} = file:list_dir(Path),
+ lists:foreach(fun(E) -> rm_rf(filename:join(Path, E)) end, Entries),
+ file:del_dir(Path);
+ false ->
+ file:delete(Path)
+ end.
diff --git a/test/er_xdg_pipe_menu_socket_tests.erl b/test/er_xdg_pipe_menu_socket_tests.erl
new file mode 100644
index 0000000..08a4b56
--- /dev/null
+++ b/test/er_xdg_pipe_menu_socket_tests.erl
@@ -0,0 +1,93 @@
+-module(er_xdg_pipe_menu_socket_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+serves_the_cached_menu_test() ->
+ Root = mk_tmp_dir(),
+ AppDir = filename:join(Root, "applications"),
+ ok = file:make_dir(AppDir),
+ write_desktop(AppDir, "foo.desktop", "Foo", "foo"),
+ SockPath = filename:join(
+ test_tmp_dir(), "eunit-socket-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ PrevSockPath = application:get_env(er_xdg_pipe_menu, socket_path),
+ application:set_env(er_xdg_pipe_menu, socket_path, SockPath),
+ with_env(
+ [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}],
+ fun() ->
+ {ok, CachePid} = er_xdg_pipe_menu_cache:start_link(),
+ {ok, SocketPid} = er_xdg_pipe_menu_socket:start_link(),
+
+ {ok, Conn} = gen_tcp:connect({local, SockPath}, 0, [binary, {active, false}], 1000),
+ Received = recv_all(Conn, []),
+ gen_tcp:close(Conn),
+
+ Expected = er_xdg_pipe_menu_cache:get_menu(),
+ stop(SocketPid),
+ stop(CachePid),
+ ?assertEqual(Expected, Received),
+ ?assert(binary:match(Received, <<"Foo">>) =/= nomatch)
+ end
+ ),
+ _ = file:delete(SockPath),
+ ok = rm_rf(Root),
+ restore_env(er_xdg_pipe_menu, socket_path, PrevSockPath).
+
+%% -- helpers ----------------------------------------------------------------
+
+%% start_link/0 links the started process to us (the test process);
+%% unlink before killing it so the kill signal doesn't propagate back
+%% and take the test process down with it.
+stop(Pid) ->
+ unlink(Pid),
+ true = erlang:exit(Pid, kill).
+
+recv_all(Socket, Acc) ->
+ case gen_tcp:recv(Socket, 0, 1000) of
+ {ok, Data} -> recv_all(Socket, [Data | Acc]);
+ {error, closed} -> iolist_to_binary(lists:reverse(Acc))
+ end.
+
+restore_env(App, Key, undefined) -> application:unset_env(App, Key);
+restore_env(App, Key, {ok, Value}) -> application:set_env(App, Key, Value).
+
+with_env(Vars, Fun) ->
+ Saved = [{Name, os:getenv(Name)} || {Name, _} <- Vars],
+ try
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Vars),
+ Fun()
+ after
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Saved)
+ end.
+
+set_env(Name, false) -> os:unsetenv(Name);
+set_env(Name, Value) -> os:putenv(Name, Value).
+
+write_desktop(Dir, FileName, Name, Exec) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary(["[Desktop Entry]\nName=", Name, "\nExec=", Exec, "\n"])
+ ).
+
+mk_tmp_dir() ->
+ Path = filename:join(
+ test_tmp_dir(), "eunit-socket-dir-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ ok = file:make_dir(Path),
+ Path.
+
+test_tmp_dir() ->
+ case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ Dir -> Dir
+ end.
+
+rm_rf(Path) ->
+ case filelib:is_dir(Path) of
+ true ->
+ {ok, Entries} = file:list_dir(Path),
+ lists:foreach(fun(E) -> rm_rf(filename:join(Path, E)) end, Entries),
+ file:del_dir(Path);
+ false ->
+ file:delete(Path)
+ end.
diff --git a/test/er_xdg_pipe_menu_watcher_tests.erl b/test/er_xdg_pipe_menu_watcher_tests.erl
new file mode 100644
index 0000000..6754f24
--- /dev/null
+++ b/test/er_xdg_pipe_menu_watcher_tests.erl
@@ -0,0 +1,86 @@
+-module(er_xdg_pipe_menu_watcher_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+%% Exercises the real debounce-timer-reset-then-invalidate wiring by
+%% sending the watcher a synthetic message of the exact shape `fs'
+%% delivers, rather than waiting on a real inotify event -- `fs'
+%% itself is treated as out of scope for this test.
+debounced_event_invalidates_cache_test() ->
+ Root = mk_tmp_dir(),
+ AppDir = filename:join(Root, "applications"),
+ ok = file:make_dir(AppDir),
+ write_desktop(AppDir, "foo.desktop", "Foo", "foo"),
+ PrevDebounce = application:get_env(er_xdg_pipe_menu, debounce_ms),
+ application:set_env(er_xdg_pipe_menu, debounce_ms, 20),
+ with_env(
+ [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}],
+ fun() ->
+ {ok, CachePid} = er_xdg_pipe_menu_cache:start_link(),
+ {ok, WatcherPid} = er_xdg_pipe_menu_watcher:start_link(),
+
+ write_desktop(AppDir, "bar.desktop", "Bar", "bar"),
+ WatcherPid ! {self(), {fs, file_event}, {"dummy", [modified]}},
+ timer:sleep(100),
+
+ Menu = er_xdg_pipe_menu_cache:get_menu(),
+ stop(WatcherPid),
+ stop(CachePid),
+ ?assert(binary:match(Menu, <<"Bar">>) =/= nomatch)
+ end
+ ),
+ ok = rm_rf(Root),
+ restore_env(er_xdg_pipe_menu, debounce_ms, PrevDebounce).
+
+%% -- helpers ----------------------------------------------------------------
+
+%% start_link/0 links the started process to us (the test process);
+%% unlink before killing it so the kill signal doesn't propagate back
+%% and take the test process down with it.
+stop(Pid) ->
+ unlink(Pid),
+ true = erlang:exit(Pid, kill).
+
+restore_env(App, Key, undefined) -> application:unset_env(App, Key);
+restore_env(App, Key, {ok, Value}) -> application:set_env(App, Key, Value).
+
+with_env(Vars, Fun) ->
+ Saved = [{Name, os:getenv(Name)} || {Name, _} <- Vars],
+ try
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Vars),
+ Fun()
+ after
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Saved)
+ end.
+
+set_env(Name, false) -> os:unsetenv(Name);
+set_env(Name, Value) -> os:putenv(Name, Value).
+
+write_desktop(Dir, FileName, Name, Exec) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary(["[Desktop Entry]\nName=", Name, "\nExec=", Exec, "\n"])
+ ).
+
+mk_tmp_dir() ->
+ Path = filename:join(
+ test_tmp_dir(), "eunit-watcher-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ ok = file:make_dir(Path),
+ Path.
+
+test_tmp_dir() ->
+ case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ Dir -> Dir
+ end.
+
+rm_rf(Path) ->
+ case filelib:is_dir(Path) of
+ true ->
+ {ok, Entries} = file:list_dir(Path),
+ lists:foreach(fun(E) -> rm_rf(filename:join(Path, E)) end, Entries),
+ file:del_dir(Path);
+ false ->
+ file:delete(Path)
+ end.