Some checks failed
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 33s
CI Code / Code Coverage (pull_request) Successful in 3m1s
CI Code / Linux (debian) (pull_request) Failing after 4m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 4m28s
CI Code / Linux (arch) (pull_request) Successful in 10m13s
Replace 16 interactive UI setup commands with pre-written profrc file containing [ui] and [notifications] sections (~1800ms saved per test). Replace sleep(1) + blocking waitpid with close(pty fd) → SIGHUP → polling waitpid(WNOHANG) → SIGTERM/SIGKILL fallback chain (~4900ms saved per test). Remove post-stbbr_start() and post-stbbr_stop() sleeps — bind+listen completes synchronously before stbbr_start() returns, and pthread_join() in stbbr_stop() guarantees socket cleanup (~200ms saved). Add PORTS_PER_GROUP=50 isolated port ranges per test group to enable safe parallel execution of 4 groups without port conflicts.
554 lines
15 KiB
C
554 lines
15 KiB
C
#include <sys/stat.h>
|
||
#include <sys/wait.h>
|
||
#include <glib.h>
|
||
|
||
#include <stdlib.h>
|
||
#include "prof_cmocka.h"
|
||
#include <stdio.h>
|
||
#include <unistd.h>
|
||
#include <errno.h>
|
||
#include <string.h>
|
||
#include <pty.h>
|
||
#include <fcntl.h>
|
||
#include <sys/select.h>
|
||
#include <regex.h>
|
||
#include <signal.h>
|
||
|
||
#include <stabber.h>
|
||
|
||
#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);
|
||
|
||
/* 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. */
|
||
#define PORTS_PER_GROUP 50
|
||
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 + 200; ++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);
|
||
|
||
/* No sleep needed: server_run() completes bind()+listen() before returning.
|
||
* Incoming connections queue in kernel backlog until accept() runs. */
|
||
|
||
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 all UI settings that were previously set via
|
||
* 16 interactive commands (each costing ~115ms of input+regex polling).
|
||
* This eliminates ~1800ms from init_prof_test. */
|
||
const char* flatfile_env = getenv("PROF_FLATFILE");
|
||
char profrc_path[512];
|
||
snprintf(profrc_path, sizeof(profrc_path),
|
||
"%s/profanity/profrc", xdg_config_home);
|
||
FILE* prc = fopen(profrc_path, "w");
|
||
if (prc) {
|
||
if (flatfile_env && strcmp(flatfile_env, "1") == 0) {
|
||
fprintf(prc, "[logging]\ndblog=flatfile\n\n");
|
||
}
|
||
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);
|
||
|
||
/* UI settings are pre-baked in profrc — no interactive commands needed. */
|
||
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). */
|
||
usleep(100000); /* 100ms grace for /quit to be read from pty buffer */
|
||
close(fd);
|
||
fd = 0;
|
||
/* Poll for exit — typically completes in <100ms */
|
||
int exited = 0;
|
||
for (int i = 0; i < 100; i++) { /* 100 × 50ms = 5s max */
|
||
if (waitpid(child_pid, NULL, WNOHANG) != 0) {
|
||
exited = 1;
|
||
break;
|
||
}
|
||
usleep(50000); /* 50ms */
|
||
}
|
||
if (!exited) {
|
||
kill(child_pid, SIGTERM);
|
||
for (int i = 0; i < 20; i++) { /* 1s grace */
|
||
if (waitpid(child_pid, NULL, WNOHANG) != 0) { exited = 1; break; }
|
||
usleep(50000);
|
||
}
|
||
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();
|
||
/* No sleep needed: stbbr_stop() calls pthread_join() which blocks until
|
||
* the server thread completes _shutdown() (close listen_socket).
|
||
* SO_REUSEADDR is set, so the port is immediately available for rebind. */
|
||
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(
|
||
"<iq type='result' to='stabber@localhost/profanity'>"
|
||
"<query xmlns='jabber:iq:roster' ver='362'>"
|
||
);
|
||
g_string_append(roster_str, roster);
|
||
g_string_append(roster_str,
|
||
"</query>"
|
||
"</iq>"
|
||
);
|
||
|
||
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(
|
||
"<presence id=\"*\">"
|
||
"<c xmlns=\"http://jabber.org/protocol/caps\" hash=\"sha-1\" node=\"http://profanity-im.github.io\" ver=\"*\"/>"
|
||
"</presence>"
|
||
));
|
||
}
|
||
|
||
void
|
||
prof_timeout(int timeout)
|
||
{
|
||
expect_timeout = timeout;
|
||
}
|
||
|
||
void
|
||
prof_timeout_reset(void)
|
||
{
|
||
expect_timeout = 60;
|
||
}
|
||
|
||
void
|
||
prof_connect(void)
|
||
{
|
||
prof_connect_with_roster(
|
||
"<item jid='buddy1@localhost' subscription='both' name='Buddy1'/>"
|
||
"<item jid='buddy2@localhost' subscription='both' name='Buddy2'/>"
|
||
);
|
||
}
|