#include #include #include #include #include "prof_cmocka.h" #include #include #include #include #include #include #include #include #include #include "proftest.h" /* Number of parallel test groups for CI builds */ #define TEST_GROUPS 4 char *config_orig; char *data_orig; int fd = 0; int stub_port = 5230; pid_t child_pid = 0; /* * Dynamic XDG paths based on stub_port for parallel test execution. * Each test instance gets unique directories to avoid file conflicts. */ char xdg_config_home[256]; char xdg_data_home[256]; /* * Buffer for accumulating output from profanity. * 64KB is sufficient for typical test output while keeping memory usage * reasonable. When full, older half is discarded (ring buffer behavior). */ #define OUTPUT_BUF_SIZE 65536 static char output_buffer[OUTPUT_BUF_SIZE]; static size_t output_len = 0; /* Timeout for expect operations in seconds */ static int expect_timeout = 30; gboolean _create_dir(const char *name) { struct stat sb; if (stat(name, &sb) != 0) { if (errno != ENOENT || mkdir(name, S_IRWXU) != 0) { return FALSE; } } else { if ((sb.st_mode & S_IFDIR) != S_IFDIR) { return FALSE; } } return TRUE; } gboolean _mkdir_recursive(const char *dir) { int i; gboolean result = TRUE; for (i = 1; i <= strlen(dir); i++) { if (dir[i] == '/' || dir[i] == '\0') { gchar *next_dir = g_strndup(dir, i); result = _create_dir(next_dir); g_free(next_dir); if (!result) { break; } } } return result; } void _create_config_dir(void) { GString *profanity_dir = g_string_new(xdg_config_home); g_string_append(profanity_dir, "/profanity"); if (!_mkdir_recursive(profanity_dir->str)) { assert_true(FALSE); } g_string_free(profanity_dir, TRUE); } void _create_data_dir(void) { GString *profanity_dir = g_string_new(xdg_data_home); g_string_append(profanity_dir, "/profanity"); if (!_mkdir_recursive(profanity_dir->str)) { assert_true(FALSE); } g_string_free(profanity_dir, TRUE); } void _create_chatlogs_dir(void) { GString *chatlogs_dir = g_string_new(xdg_data_home); g_string_append(chatlogs_dir, "/profanity/chatlogs"); if (!_mkdir_recursive(chatlogs_dir->str)) { assert_true(FALSE); } g_string_free(chatlogs_dir, TRUE); } void _create_logs_dir(void) { GString *logs_dir = g_string_new(xdg_data_home); g_string_append(logs_dir, "/profanity/logs"); if (!_mkdir_recursive(logs_dir->str)) { assert_true(FALSE); } g_string_free(logs_dir, TRUE); } void _cleanup_dirs(void) { const char *group_env = getenv("PROF_TEST_GROUP"); int group = group_env ? atoi(group_env) : 0; int dir_id = (group >= 1 && group <= TEST_GROUPS) ? group : stub_port; printf("[PROF_TEST] Cleaning up directories for group %d (dir_id %d)\n", group, dir_id); char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf ./test-files/%d", dir_id); int res = system(cmd); if (res == -1) { assert_true(FALSE); } } /* * Read available data from fd into output_buffer with timeout. * Returns number of bytes read, 0 on timeout, -1 on error. */ static int _read_output(int timeout_ms) { fd_set readfds; struct timeval tv; FD_ZERO(&readfds); FD_SET(fd, &readfds); tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; int ret = select(fd + 1, &readfds, NULL, NULL, &tv); if (ret <= 0) { return ret; } size_t space = OUTPUT_BUF_SIZE - output_len - 1; if (space <= 0) { /* Buffer full, shift content */ memmove(output_buffer, output_buffer + OUTPUT_BUF_SIZE/2, OUTPUT_BUF_SIZE/2); output_len = OUTPUT_BUF_SIZE/2; space = OUTPUT_BUF_SIZE - output_len - 1; } ssize_t n = read(fd, output_buffer + output_len, space); if (n > 0) { output_len += n; output_buffer[output_len] = '\0'; } return n; } /* * Custom implementation of exp_spawnl using forkpty. * This avoids the segfault bug in libexpect on Arch Linux. */ void prof_start(void) { struct winsize ws; ws.ws_row = 24; ws.ws_col = 300; /* Match COLUMNS=300 from start_profanity.sh */ ws.ws_xpixel = 0; ws.ws_ypixel = 0; /* Reset output buffer */ output_len = 0; output_buffer[0] = '\0'; child_pid = forkpty(&fd, NULL, NULL, &ws); if (child_pid < 0) { fd = -1; return; } if (child_pid == 0) { /* Child process */ setenv("COLUMNS", "300", 1); setenv("TERM", "xterm", 1); execl("./profanity", "./profanity", "-l", "DEBUG", NULL); /* If exec fails */ fprintf(stderr, "execl failed: %s\n", strerror(errno)); _exit(127); } /* Parent process */ /* Set non-blocking mode for reading */ int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); /* Brief wait for process to initialize */ usleep(50000); /* 50ms */ } int init_prof_test(void **state) { /* Get test group from environment for static resource allocation */ const char *group_env = getenv("PROF_TEST_GROUP"); int group = group_env ? atoi(group_env) : 0; /* Get build index for port offset (for parallel CI builds) */ const char *build_env = getenv("PROF_BUILD_INDEX"); int build_idx = build_env ? atoi(build_env) : 0; /* Calculate port base: each build uses a different range of TEST_GROUPS ports. * Build 0 (local/default): 5230-5233, Full: 5230-5233, Minimal: 5234-5237, etc. * Build 0 and Full share the same range because build 0 is for local runs or sequential run (no parallel builds), * while Full/Minimal/NoEncrypt/Default are used in CI where they run in parallel. */ int port_base = 5230 + ((build_idx > 0 ? build_idx - 1 : 0) * TEST_GROUPS); /* Static resource allocation to avoid conflicts in parallel execution. * Group 1-4: use static port assignment. * Group 0 (all groups): use dynamic allocation as fallback. */ gboolean started = FALSE; if (group >= 1 && group <= TEST_GROUPS) { /* Static allocation: each group gets a dedicated port */ stub_port = port_base + group - 1; printf("[PROF_TEST] Build %d, Group %d: trying port %d\n", build_idx, group, stub_port); if (stbbr_start(STBBR_LOGDEBUG, stub_port, 0) == 0) { started = TRUE; printf("[PROF_TEST] Started stabber on port %d\n", stub_port); } else { printf("[PROF_TEST] Failed to start stabber on port %d\n", stub_port); } } /* Fallback to dynamic allocation if static failed or group=0 */ if (!started) { printf("[PROF_TEST] Using dynamic port allocation\n"); for (int p = port_base; p < port_base + 20; ++p) { if (stbbr_start(STBBR_LOGDEBUG, p, 0) == 0) { stub_port = p; started = TRUE; printf("[PROF_TEST] Started stabber on port %d\n", stub_port); break; } } } if (!started) { fprintf(stderr, "[PROF_TEST] ERROR: could not start stabber on any port\n"); return -1; } /* Generate unique XDG paths based on group for parallel execution. * Use ./test-files/ in current (build) directory for out-of-tree builds compatibility. */ int dir_id = (group >= 1 && group <= TEST_GROUPS) ? group : stub_port; snprintf(xdg_config_home, sizeof(xdg_config_home), "./test-files/%d/xdg_config_home", dir_id); snprintf(xdg_data_home, sizeof(xdg_data_home), "./test-files/%d/xdg_data_home", dir_id); printf("[PROF_TEST] Group %d using directories: config=%s, data=%s\n", group, xdg_config_home, xdg_data_home); // Give stabber server thread time to start listening usleep(100000); // 100ms config_orig = getenv("XDG_CONFIG_HOME"); data_orig = getenv("XDG_DATA_HOME"); setenv("XDG_CONFIG_HOME", xdg_config_home, 1); setenv("XDG_DATA_HOME", xdg_data_home, 1); _cleanup_dirs(); _create_config_dir(); _create_data_dir(); _create_chatlogs_dir(); _create_logs_dir(); prof_start(); int prof_started = prof_output_regex("CProof\\. Type /help for help information\\."); assert_true(prof_started); // set UI options to make expect assertions faster and more reliable prof_input("/inpblock timeout 5"); assert_true(prof_output_regex("Input blocking set to 5 milliseconds")); prof_input("/inpblock dynamic off"); assert_true(prof_output_regex("Dynamic input blocking disabled")); prof_input("/notify chat off"); assert_true(prof_output_regex("Chat notifications disabled")); prof_input("/notify room off"); assert_true(prof_output_regex("Room notifications disabled")); prof_input("/wrap off"); assert_true(prof_output_regex("Word wrap disabled")); prof_input("/roster hide"); assert_true(prof_output_regex("Roster disabled")); prof_input("/occupants default hide"); assert_true(prof_output_regex("Occupant list disabled")); prof_input("/time console off"); prof_input("/time console off"); assert_true(prof_output_regex("Console time display disabled\\.")); prof_input("/time chat off"); assert_true(prof_output_regex("Chat time display disabled\\.")); prof_input("/time muc off"); assert_true(prof_output_regex("MUC time display disabled\\.")); prof_input("/time config off"); assert_true(prof_output_regex("Config time display disabled\\.")); prof_input("/time private off"); assert_true(prof_output_regex("Private chat time display disabled\\.")); prof_input("/time xml off"); assert_true(prof_output_regex("XML Console time display disabled\\.")); return 0; } int close_prof_test(void **state) { if (fd > 0 && child_pid > 0) { prof_input("/quit"); // Give profanity time to process quit command sleep(1); waitpid(child_pid, NULL, 0); close(fd); fd = 0; child_pid = 0; } _cleanup_dirs(); if (config_orig) { setenv("XDG_CONFIG_HOME", config_orig, 1); } if (data_orig) { setenv("XDG_DATA_HOME", data_orig, 1); } stbbr_stop(); /* * TODO: Replace with proper synchronization. * stabber doesn't provide wait_stopped() API yet, so we use delay * to ensure the port is released before the next test starts. * See: https://git.jabber.space/devs/stabber/issues/3 */ usleep(100000); // 100ms return 0; } void prof_input(const char *input) { GString *inp_str = g_string_new(input); g_string_append(inp_str, "\r"); ssize_t _wn = write(fd, inp_str->str, inp_str->len); (void)_wn; g_string_free(inp_str, TRUE); /* Small delay to let profanity process input */ usleep(10000); } /* * Wait for exact text to appear in output. * Returns 1 if found, 0 if timeout. */ int prof_output_exact(const char *text) { time_t start = time(NULL); while (time(NULL) - start < expect_timeout) { /* Read any available output */ while (_read_output(100) > 0) { /* Keep reading while data available */ } /* Check if text is in buffer */ if (strstr(output_buffer, text) != NULL) { return 1; } usleep(50000); /* 50ms */ } return 0; } /* * Wait for regex pattern to match in output. * Returns 1 if found, 0 if timeout. */ int prof_output_regex(const char *pattern) { regex_t regex; int ret; ret = regcomp(®ex, pattern, REG_EXTENDED | REG_NOSUB); if (ret != 0) { return 0; } time_t start = time(NULL); while (time(NULL) - start < expect_timeout) { /* Read any available output */ while (_read_output(100) > 0) { /* Keep reading while data available */ } /* Check if pattern matches */ ret = regexec(®ex, output_buffer, 0, NULL, 0); if (ret == 0) { regfree(®ex); return 1; } usleep(50000); /* 50ms */ } /* Timeout reached - log diagnostic info */ fprintf(stderr, "Timeout waiting for regex '%s' after %d seconds. Last output:\n", pattern, expect_timeout); size_t len = strlen(output_buffer); if (len > 500) { fprintf(stderr, "...%s", output_buffer + len - 500); } else { fprintf(stderr, "%s", output_buffer); } fprintf(stderr, "\n"); regfree(®ex); return 0; } void prof_connect_with_roster(const char *roster) { GString *roster_str = g_string_new( "" "" ); g_string_append(roster_str, roster); g_string_append(roster_str, "" "" ); stbbr_for_query("jabber:iq:roster", roster_str->str); g_string_free(roster_str, TRUE); stbbr_auth_passwd("password"); char connect_cmd[128]; snprintf(connect_cmd, sizeof(connect_cmd), "/connect stabber@localhost server 127.0.0.1 port %d tls disable auth legacy", stub_port); prof_input(connect_cmd); assert_true(prof_output_regex("password:")); prof_input("password"); expect_timeout = 60; assert_true(prof_output_regex("Connecting as stabber@localhost")); assert_true(prof_output_regex("logged in successfully")); assert_true(prof_output_regex(".+online.+ \\(priority 0\\)\\.")); expect_timeout = 60; // Wait for presence stanza to be sent (content-based, not ID-based) // Match the actual attribute order from stanza_attach_caps assert_true(stbbr_received( "" "" "" )); } void prof_timeout(int timeout) { expect_timeout = timeout; } void prof_timeout_reset(void) { expect_timeout = 60; } void prof_connect(void) { prof_connect_with_roster( "" "" ); }