#include #include #include #include #include "prof_cmocka.h" #include #include #include #include #include #include #include #include #include #include #include #include "proftest.h" /* Number of parallel test groups for CI builds */ #define TEST_GROUPS 5 /* Number of ports reserved per test group; allows skipping TIME_WAIT ports */ #define PORTS_PER_GROUP 50 /* Base port and fallback scan range for stabber */ #define BASE_PORT 5230 #define FALLBACK_PORT_RANGE (TEST_GROUPS * PORTS_PER_GROUP) /* Shutdown polling: interval and maximum attempts before escalating signals */ #define MS_TO_US 1000 #define SHUTDOWN_POLL_MS 50 #define SHUTDOWN_POLL_MAX 100 /* 100 x 50ms = 5s */ #define SIGTERM_POLL_MAX 20 /* 20 x 50ms = 1s */ #define QUIT_GRACE_MS 100 /* grace for /quit to be read from pty */ #define INIT_WAIT_MS 50 /* wait for child process to initialize */ #define INPUT_DELAY_MS 10 /* let profanity process input */ #define OUTPUT_POLL_MS 50 /* polling interval for output checks */ #define READ_TIMEOUT_MS 100 /* select() timeout for pty reads */ /* Expect timeouts (seconds) */ #define EXPECT_TIMEOUT_DEFAULT 30 #define EXPECT_TIMEOUT_CONNECT 60 /* Test duration thresholds (seconds) */ #define SLOW_TEST_THRESHOLD_S 20 #define FAST_TEST_THRESHOLD_S 0.3 /* Sentinel "infinity" for min-elapsed tracking (any real test is faster) */ #define MIN_ELAPSED_INIT 1e9 /* Terminal dimensions for forkpty */ #define PTY_ROWS 24 #define PTY_COLS 300 /* Preprocessor stringification (for setenv from numeric #define) */ #define STRINGIFY_(x) #x #define STRINGIFY(x) STRINGIFY_(x) char* config_orig; char* data_orig; int fd = 0; int stub_port = BASE_PORT; 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 = EXPECT_TIMEOUT_DEFAULT; /* Per-test wall-clock timer */ static struct timespec test_start_ts; static const char* current_test_name; static double min_elapsed = MIN_ELAPSED_INIT; static const char* min_test_name = "(none)"; /* Per-test threshold overrides (0 = use default) */ double prof_test_slow_threshold = 0; double prof_test_fast_threshold = 0; /* Timezone for profanity child process (see proftest.h) */ const char* prof_test_tz = "UTC"; 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) { gboolean result = TRUE; for (size_t 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); } } static void sleep_ms(int ms) { usleep(ms * MS_TO_US); } /* * Read available data from fd into output_buffer with timeout. * Returns number of bytes read, 0 on timeout, -1 on error. */ static ssize_t _read_output(int timeout_ms) { fd_set readfds; struct timeval tv; FD_ZERO(&readfds); FD_SET(fd, &readfds); tv.tv_sec = timeout_ms / MS_TO_US; tv.tv_usec = (timeout_ms % MS_TO_US) * MS_TO_US; int ret = select(fd + 1, &readfds, NULL, NULL, &tv); if (ret <= 0) { return ret; } /* Check size before subtracting so OUTPUT_BUF_SIZE - output_len - 1 * never goes negative / underflows. Reserves one byte for the * trailing NUL. */ if (output_len >= OUTPUT_BUF_SIZE - 1) { /* Buffer full, shift content */ memmove(output_buffer, output_buffer + OUTPUT_BUF_SIZE / 2, OUTPUT_BUF_SIZE / 2); output_len = OUTPUT_BUF_SIZE / 2; } size_t space = OUTPUT_BUF_SIZE - output_len - 1; ssize_t n = read(fd, output_buffer + output_len, space); if (n > 0) { output_len += (size_t)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 = PTY_ROWS; ws.ws_col = PTY_COLS; ws.ws_xpixel = 0; ws.ws_ypixel = 0; /* Reset output buffer */ output_len = 0; output_buffer[0] = '\0'; /* Force UTC so delay-stamp timestamps render deterministically * regardless of the host timezone (needed for functional tests * that verify rendered timestamp strings). */ setenv("TZ", prof_test_tz, 1); tzset(); child_pid = forkpty(&fd, NULL, NULL, &ws); if (child_pid < 0) { fd = -1; return; } if (child_pid == 0) { /* Child process */ setenv("COLUMNS", STRINGIFY(PTY_COLS), 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 */ sleep_ms(INIT_WAIT_MS); } int init_prof_test(void** state) { clock_gettime(CLOCK_MONOTONIC, &test_start_ts); current_test_name = state && *state ? (const char*)*state : "unknown"; prof_test_slow_threshold = 0; prof_test_fast_threshold = 0; /* 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_PER_GROUP ports. * Build 0 (local/default): 5230-5429, Full: 5230-5429, Minimal: 5430-5629, 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 = BASE_PORT + ((build_idx > 0 ? build_idx - 1 : 0) * TEST_GROUPS * PORTS_PER_GROUP); /* Each group gets a dedicated range of ports for parallel execution. * Group 1: port_base..port_base+PORTS_PER_GROUP-1 * Group 2: port_base+PORTS_PER_GROUP..port_base+2*PORTS_PER_GROUP-1, etc. * Ports in TIME_WAIT from previous tests are skipped automatically. */ gboolean started = FALSE; if (group >= 1 && group <= TEST_GROUPS) { int group_port_base = port_base + (group - 1) * PORTS_PER_GROUP; for (int p = group_port_base; p < group_port_base + PORTS_PER_GROUP && !started; p++) { if (stbbr_start(STBBR_LOGDEBUG, p, 0) == 0) { stub_port = p; started = TRUE; } } if (!started) { fprintf(stderr, "[PROF_TEST] Failed to start stabber on ports %d-%d\n", group_port_base, group_port_base + PORTS_PER_GROUP - 1); } } /* Fallback to dynamic allocation if static failed or group=0 */ if (!started) { for (int p = port_base; p < port_base + FALLBACK_PORT_RANGE; ++p) { if (stbbr_start(STBBR_LOGDEBUG, p, 0) == 0) { stub_port = p; started = TRUE; 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); 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(); /* Pre-write profrc with UI/notification defaults to ensure consistent, * deterministic output across tests (no timestamps, no roster/occupants * panels, low input-blocking delay for fast command processing). */ char profrc_path[512]; snprintf(profrc_path, sizeof(profrc_path), "%s/profanity/profrc", xdg_config_home); FILE* prc = fopen(profrc_path, "w"); if (prc) { fprintf(prc, "[ui]\n" "inpblock=5\n" "inpblock.dynamic=false\n" "wrap=false\n" "roster=false\n" "occupants=false\n" "time.console=off\n" "time.chat=off\n" "time.muc=off\n" "time.config=off\n" "time.private=off\n" "time.xmlconsole=off\n" "[notifications]\n" "message=false\n" "room=false\n"); fclose(prc); } prof_start(); int prof_started = prof_output_regex("CProof\\. Type /help for help information\\."); assert_true(prof_started); return 0; } int close_prof_test(void** state) { if (fd > 0 && child_pid > 0) { prof_input("/quit"); /* Close pty master after brief grace — child gets SIGHUP which * accelerates shutdown instead of waiting for XMPP disconnect * timeout (~5s). */ sleep_ms(QUIT_GRACE_MS); close(fd); fd = 0; int exited = 0; for (int i = 0; i < SHUTDOWN_POLL_MAX; i++) { if (waitpid(child_pid, NULL, WNOHANG) != 0) { exited = 1; break; } sleep_ms(SHUTDOWN_POLL_MS); } if (!exited) { kill(child_pid, SIGTERM); for (int i = 0; i < SIGTERM_POLL_MAX; i++) { if (waitpid(child_pid, NULL, WNOHANG) != 0) { exited = 1; break; } sleep_ms(SHUTDOWN_POLL_MS); } if (!exited) { kill(child_pid, SIGKILL); waitpid(child_pid, NULL, 0); } } child_pid = 0; } if (config_orig) { setenv("XDG_CONFIG_HOME", config_orig, 1); } if (data_orig) { setenv("XDG_DATA_HOME", data_orig, 1); } stbbr_stop(); struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); double elapsed = (now.tv_sec - test_start_ts.tv_sec) + (now.tv_nsec - test_start_ts.tv_nsec) / 1e9; if (elapsed < min_elapsed) { min_elapsed = elapsed; min_test_name = current_test_name; } printf("[PROF_TEST] test '%s' took %.3f s (min: %.3fs '%s')\n", current_test_name, elapsed, min_elapsed, min_test_name); double slow_thr = prof_test_slow_threshold > 0 ? prof_test_slow_threshold : SLOW_TEST_THRESHOLD_S; double fast_thr = prof_test_fast_threshold > 0 ? prof_test_fast_threshold : FAST_TEST_THRESHOLD_S; if (elapsed >= slow_thr) { print_message("[PROF_TEST] WARNING: slow test '%s' \u2014 %.1fs (threshold %.1fs)\n", current_test_name, elapsed, slow_thr); } else if (elapsed < fast_thr) { print_message("[PROF_TEST] WARNING: suspiciously fast test '%s' \u2014 %.3fs (min %.3fs)\n", current_test_name, elapsed, fast_thr); } 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 */ sleep_ms(INPUT_DELAY_MS); } /* * 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(READ_TIMEOUT_MS) > 0) { /* Keep reading while data available */ } /* Check if text is in buffer */ if (strstr(output_buffer, text) != NULL) { return 1; } sleep_ms(OUTPUT_POLL_MS); } 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(READ_TIMEOUT_MS) > 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; } sleep_ms(OUTPUT_POLL_MS); } /* 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 = EXPECT_TIMEOUT_CONNECT; 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 = EXPECT_TIMEOUT_CONNECT; // Wait for presence stanza to be sent // 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 = EXPECT_TIMEOUT_CONNECT; } void prof_connect(void) { prof_connect_with_roster( "" ""); }