diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/common/test_desktop.c | 347 | ||||
| -rw-r--r-- | tests/meson.build | 18 | ||||
| -rw-r--r-- | tests/test_api.cpp | 6 |
3 files changed, 371 insertions, 0 deletions
diff --git a/tests/common/test_desktop.c b/tests/common/test_desktop.c new file mode 100644 index 0000000..f65a9cd --- /dev/null +++ b/tests/common/test_desktop.c @@ -0,0 +1,347 @@ +#define _GNU_SOURCE +#include "common/backend.h" +#include "yasl/desktop.h" + +#include <assert.h> +#include <fcntl.h> +#include <limits.h> +#include <pthread.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +#if YASL_HAS_DBUS +#include <dbus/dbus.h> +#endif + +static void sleep_ms(int ms) +{ + struct timespec ts = {.tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +} + +static char *read_file(const char *path) +{ + FILE *f = fopen(path, "r"); + if (!f) return NULL; + static char buf[16384]; + size_t n = fread(buf, 1, sizeof(buf) - 1, f); + fclose(f); + buf[n] = '\0'; + return buf; +} + +static void test_buffers(void) +{ + char exe[PATH_MAX], real[PATH_MAX]; + size_t len = yasl_executable_path(NULL, 0); + assert(len > 0); + assert(yasl_executable_path(exe, len) == len); + assert(yasl_executable_path(exe, sizeof(exe)) == len && exe[len] == '\0'); + assert(realpath("/proc/self/exe", real) && strcmp(exe, real) == 0); + /* A short buffer stays untouched. */ + char small[4] = "abc"; + assert(yasl_executable_path(small, sizeof(small)) == len); + assert(strcmp(small, "abc") == 0); + assert(yasl_executable_path(NULL, 8) == 0); + + assert(yasl_resource_dir(exe, sizeof(exe)) == 0); + + char locale[64]; + setenv("LC_ALL", "", 1); + setenv("LC_CTYPE", "de_DE.UTF-8", 1); + setenv("LANG", "fr_FR.UTF-8", 1); + assert(yasl_default_locale(locale, sizeof(locale)) == 11); + assert(strcmp(locale, "de_DE.UTF-8") == 0); + unsetenv("LC_CTYPE"); + assert(yasl_default_locale(locale, sizeof(locale)) == 11); + assert(strcmp(locale, "fr_FR.UTF-8") == 0); + unsetenv("LANG"); + unsetenv("LC_ALL"); + assert(yasl_default_locale(locale, sizeof(locale)) == 7); + assert(strcmp(locale, "C.UTF-8") == 0); +} + +/* A fake xdg-open records its argument, signal state, and descriptors. */ +static void test_open_uri(const char *dir) +{ + char script[PATH_MAX], out[PATH_MAX]; + snprintf(script, sizeof(script), "%s/xdg-open", dir); + snprintf(out, sizeof(out), "%s/opened", dir); + FILE *f = fopen(script, "w"); + assert(f); + fprintf(f, "#!/bin/sh\n" + "{ printf '%%s\\n' \"$1\"; grep -E '^Sig(Blk|Ign)' /proc/self/status;\n" + " ls /proc/$$/fd; } > '%s.tmp'\n" + "mv '%s.tmp' '%s'\n", out, out, out); + fclose(f); + assert(chmod(script, 0755) == 0); + const char *old_path = getenv("PATH"); + char path[8192]; + snprintf(path, sizeof(path), "%s:%s", dir, old_path ? old_path : "/usr/bin:/bin"); + setenv("PATH", path, 1); + + struct yasl_display display = {0}; + assert(!yasl_display_open_uri(NULL, "https://example.com")); + assert(!yasl_display_open_uri(&display, NULL)); + assert(!yasl_display_open_uri(&display, "")); + assert(!yasl_display_open_uri(&display, "example.com")); + assert(!yasl_display_open_uri(&display, "-x:option")); + assert(!yasl_display_open_uri(&display, "1http://example.com")); + assert(!yasl_display_open_uri(&display, "http://example.com/\n")); + assert(access(out, F_OK) != 0); + + /* The opener must not inherit blocked or ignored signals or descriptors. */ + sigset_t block, old_mask; + sigemptyset(&block); + sigaddset(&block, SIGUSR1); + pthread_sigmask(SIG_BLOCK, &block, &old_mask); + struct sigaction ignore = {.sa_handler = SIG_IGN}, old_pipe; + sigemptyset(&ignore.sa_mask); + sigaction(SIGPIPE, &ignore, &old_pipe); + int null = open("/dev/null", O_RDONLY); + int leaked = fcntl(null, F_DUPFD, 200); + assert(null >= 0 && leaked >= 200); + close(null); + + const char *uri = "https://example.com/a b?c='d'&e=\"f\""; + assert(yasl_display_open_uri(&display, uri)); + char *text = NULL; + for (int i = 0; i < 500 && !(text = read_file(out)); i++) sleep_ms(10); + assert(text); + char *line = strtok(text, "\n"); + assert(line && strcmp(line, uri) == 0); + int fds = 0; + while ((line = strtok(NULL, "\n"))) { + if (strncmp(line, "Sig", 3) == 0) { + const char *mask = strchr(line, ':') + 1; + while (*mask == '\t' || *mask == ' ') mask++; + assert(strspn(mask, "0") == strlen(mask)); + } else { + assert(atoi(line) != leaked); + fds++; + } + } + assert(fds >= 3); + + close(leaked); + sigaction(SIGPIPE, &old_pipe, NULL); + pthread_sigmask(SIG_SETMASK, &old_mask, NULL); + unlink(out); + unlink(script); + + /* Without xdg-open in PATH, nothing launches. */ + setenv("PATH", dir, 1); + assert(!yasl_display_open_uri(&display, "https://example.com")); + if (old_path) setenv("PATH", old_path, 1); +} + +static void test_appearance_fallback(void) +{ + struct yasl_display display = {0}; + assert(yasl_display_appearance(NULL) == 0); + unsetenv("GTK_THEME"); + assert(yasl_display_appearance(&display) == 0); + setenv("GTK_THEME", "Adwaita:dark", 1); + assert(yasl_display_appearance(&display) == YASL_APPEARANCE_DARK); + setenv("GTK_THEME", "HighContrast", 1); + assert(yasl_display_appearance(&display) == YASL_APPEARANCE_HIGH_CONTRAST); + setenv("GTK_THEME", "HighContrastInverse", 1); + assert(yasl_display_appearance(&display) == + (YASL_APPEARANCE_HIGH_CONTRAST | YASL_APPEARANCE_DARK)); + unsetenv("GTK_THEME"); + assert(!yasl_display_notify(NULL, "title", "body")); + assert(!yasl_display_notify(&display, NULL, "body")); + assert(!yasl_display_notify(&display, "title", "body")); +} + +#if YASL_HAS_DBUS +/* A fake notification server and Settings portal on a private bus. */ +struct fake_bus { + DBusConnection *conn; + pthread_mutex_t lock; + char app[64], title[64], body[64], entry[64]; + int notifications; + bool read_one; + _Atomic bool stop; +}; + +static void reply_setting(DBusConnection *c, DBusMessage *msg, bool nested) +{ + const char *ns = NULL, *key = NULL; + dbus_message_get_args(msg, NULL, DBUS_TYPE_STRING, &ns, DBUS_TYPE_STRING, &key, + DBUS_TYPE_INVALID); + dbus_uint32_t value; + if (ns && key && strcmp(ns, "org.freedesktop.appearance") == 0 && + strcmp(key, "color-scheme") == 0) { + value = 1; + } else if (ns && key && strcmp(ns, "org.freedesktop.appearance") == 0 && + strcmp(key, "contrast") == 0) { + value = 1; + } else { + DBusMessage *err = dbus_message_new_error(msg, + "org.freedesktop.portal.Error.NotFound", "Requested setting not found"); + dbus_connection_send(c, err, NULL); + dbus_message_unref(err); + return; + } + DBusMessage *reply = dbus_message_new_method_return(msg); + DBusMessageIter it, outer, inner; + dbus_message_iter_init_append(reply, &it); + if (nested) { + dbus_message_iter_open_container(&it, DBUS_TYPE_VARIANT, "v", &outer); + dbus_message_iter_open_container(&outer, DBUS_TYPE_VARIANT, "u", &inner); + dbus_message_iter_append_basic(&inner, DBUS_TYPE_UINT32, &value); + dbus_message_iter_close_container(&outer, &inner); + dbus_message_iter_close_container(&it, &outer); + } else { + dbus_message_iter_open_container(&it, DBUS_TYPE_VARIANT, "u", &outer); + dbus_message_iter_append_basic(&outer, DBUS_TYPE_UINT32, &value); + dbus_message_iter_close_container(&it, &outer); + } + dbus_connection_send(c, reply, NULL); + dbus_message_unref(reply); +} + +static void record_notify(struct fake_bus *fb, DBusMessage *msg) +{ + DBusMessageIter it, sub, entry, value; + const char *app, *icon, *title, *body, *hint; + dbus_uint32_t replaces; + dbus_int32_t expire; + assert(dbus_message_has_signature(msg, "susssasa{sv}i")); + dbus_message_iter_init(msg, &it); + dbus_message_iter_get_basic(&it, &app); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &replaces); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &icon); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &title); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &body); + dbus_message_iter_next(&it); + dbus_message_iter_next(&it); + dbus_message_iter_recurse(&it, &sub); + dbus_message_iter_recurse(&sub, &entry); + dbus_message_iter_get_basic(&entry, &hint); + assert(strcmp(hint, "desktop-entry") == 0); + dbus_message_iter_next(&entry); + dbus_message_iter_recurse(&entry, &value); + const char *desktop_entry; + dbus_message_iter_get_basic(&value, &desktop_entry); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &expire); + assert(replaces == 0 && expire == -1 && icon[0] == '\0'); + + pthread_mutex_lock(&fb->lock); + snprintf(fb->app, sizeof(fb->app), "%s", app); + snprintf(fb->title, sizeof(fb->title), "%s", title); + snprintf(fb->body, sizeof(fb->body), "%s", body); + snprintf(fb->entry, sizeof(fb->entry), "%s", desktop_entry); + fb->notifications++; + pthread_mutex_unlock(&fb->lock); +} + +static void *serve(void *ud) +{ + struct fake_bus *fb = ud; + while (!fb->stop && dbus_connection_read_write(fb->conn, 50)) { + DBusMessage *msg; + while ((msg = dbus_connection_pop_message(fb->conn))) { + if (dbus_message_is_method_call(msg, "org.freedesktop.Notifications", "Notify")) { + record_notify(fb, msg); + } else if (dbus_message_is_method_call(msg, "org.freedesktop.portal.Settings", "ReadOne")) { + if (fb->read_one) { + reply_setting(fb->conn, msg, false); + } else { + DBusMessage *err = dbus_message_new_error(msg, DBUS_ERROR_UNKNOWN_METHOD, "old"); + dbus_connection_send(fb->conn, err, NULL); + dbus_message_unref(err); + } + } else if (dbus_message_is_method_call(msg, "org.freedesktop.portal.Settings", "Read")) { + reply_setting(fb->conn, msg, true); + } + dbus_message_unref(msg); + } + } + return NULL; +} + +static void test_bus(const char *address) +{ + DBusError err; + dbus_error_init(&err); + struct fake_bus fb = {.read_one = true}; + pthread_mutex_init(&fb.lock, NULL); + fb.conn = dbus_connection_open_private(address, &err); + assert(fb.conn && dbus_bus_register(fb.conn, &err)); + assert(dbus_bus_request_name(fb.conn, "org.freedesktop.Notifications", 0, &err) == + DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER); + assert(dbus_bus_request_name(fb.conn, "org.freedesktop.portal.Desktop", 0, &err) == + DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER); + pthread_t thread; + assert(pthread_create(&thread, NULL, serve, &fb) == 0); + + struct yasl_display display = {.app_id = "org.example.Test"}; + assert(yasl_display_notify(&display, "Build done", NULL)); + for (int i = 0; i < 500; i++) { + pthread_mutex_lock(&fb.lock); + int n = fb.notifications; + pthread_mutex_unlock(&fb.lock); + if (n) break; + sleep_ms(10); + } + pthread_mutex_lock(&fb.lock); + assert(fb.notifications == 1); + assert(strcmp(fb.app, "org.example.Test") == 0); + assert(strcmp(fb.entry, "org.example.Test") == 0); + assert(strcmp(fb.title, "Build done") == 0 && fb.body[0] == '\0'); + pthread_mutex_unlock(&fb.lock); + + /* Portal settings win over GTK_THEME, through ReadOne or the older Read. */ + setenv("GTK_THEME", "Adwaita", 1); + uint32_t want = YASL_APPEARANCE_DARK | YASL_APPEARANCE_HIGH_CONTRAST; + assert(yasl_display_appearance(&display) == want); + fb.read_one = false; + assert(yasl_display_appearance(&display) == want); + unsetenv("GTK_THEME"); + + fb.stop = true; + pthread_join(thread, NULL); + dbus_connection_close(fb.conn); + dbus_connection_unref(fb.conn); + pthread_mutex_destroy(&fb.lock); +} +#endif + +int main(void) +{ + char dir[] = "/tmp/yasl-desktop-XXXXXX"; + assert(mkdtemp(dir)); + test_buffers(); + test_open_uri(dir); + + /* Hide the session bus so that desktop services fall back. */ + char *address = getenv("DBUS_SESSION_BUS_ADDRESS"); + address = address ? strdup(address) : NULL; + unsetenv("DBUS_SESSION_BUS_ADDRESS"); + setenv("XDG_RUNTIME_DIR", dir, 1); + test_appearance_fallback(); +#if YASL_HAS_DBUS + if (address) { + setenv("DBUS_SESSION_BUS_ADDRESS", address, 1); + test_bus(address); + } else { + printf("Skipping D-Bus checks: no session bus.\n"); + } +#endif + free(address); + rmdir(dir); + printf("Desktop tests passed.\n"); + return 0; +} diff --git a/tests/meson.build b/tests/meson.build index f072d3f..84b6847 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -52,6 +52,7 @@ if system == 'linux' or system == 'darwin' exported_headers = files( '../include/yasl/yasl.h', '../include/yasl/app.h', + '../include/yasl/desktop.h', '../include/yasl/timing.h', '../include/yasl/loop.h', '../include/yasl/native.h', @@ -117,6 +118,23 @@ loop_test = executable( ) test('loop', loop_test) +if system == 'linux' + desktop_test = executable( + 'yasl-desktop-test', + 'common/test_desktop.c', + include_directories: inc, + dependencies: [yasl_dep, dependency('threads')], + ) + # A private session bus keeps the notification and portal checks away + # from the user's desktop. + dbus_run_session = find_program('dbus-run-session', required: false) + if has_dbus and dbus_run_session.found() + test('desktop', dbus_run_session, args: ['--', desktop_test]) + else + test('desktop', desktop_test) + endif +endif + app_test = executable( 'yasl-app-test', 'common/test_app.c', diff --git a/tests/test_api.cpp b/tests/test_api.cpp index c5921e6..bc54323 100644 --- a/tests/test_api.cpp +++ b/tests/test_api.cpp @@ -8,6 +8,7 @@ #if YASL_HAS_COCOA #include "yasl/native/cocoa.h" #endif +#include "yasl/desktop.h" #include "yasl/loop.h" #include <yasl/timing.h> #include <yasl/yasl.h> @@ -63,5 +64,10 @@ int main() assert(!yasl_cocoa_surface_get_view(nullptr)); #endif + assert(!yasl_display_open_uri(nullptr, "https://example.com")); + assert(yasl_display_appearance(nullptr) == 0); + assert(!yasl_surface_bell(nullptr)); + assert(yasl_executable_path(nullptr, 0) > 0); + return 0; } |
