Files
cproof/tests/functionaltests/proftest.c
Jabber Developer 9e5dfb14f8
All checks were successful
CI Code / Check spelling (push) Successful in 18s
CI Code / Check coding style (push) Successful in 29s
CI Code / Code Coverage (push) Successful in 2m44s
CI Code / Linux (debian) (push) Successful in 4m46s
CI Code / Linux (ubuntu) (push) Successful in 4m59s
CI Code / Linux (arch) (push) Successful in 5m56s
feat(ai): add AI client with multi-provider chat support
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom endpoints) to provide
AI-assisted chat within CProof. Users can start sessions with /ai start,
send prompts, receive responses in a dedicated AI window, switch between
providers and models, and manage API keys — all with tab-completion.

Providers are configured via /ai set commands with per-provider API keys,
endpoints, default models, and custom settings. Two default providers
(openai, perplexity) are seeded on first use. Provider state persists in
[ai/<name>] sections of the preferences keyfile with automatic migration
from the previous flat-key format.

The /ai command integrates into the existing command system with 8
subcommands covering provider management, session lifecycle, model
fetching, and conversation clearing. Autocomplete uses the standard
flat prefix-matching chain for reliable tab-completion at every nesting
level. A new ProfAiWin window type is added to the window system.

Architecture:

Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls
Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call
Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s)
Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training
Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM
JSON parsing uses unified helpers for both chat responses and error
envelopes with consistent escape decoding. The response parser tries
Perplexity /v1/responses "text" field first, then falls back to OpenAI
"content". Error parsing extracts provider error.message from the
standard envelope format. Model parsing handles multiple API response
formats (OpenAI list, Perplexity, array) including edge cases.

Tests include 470+ lines of unit tests covering provider management,
session lifecycle, JSON parsing (multiple formats), autocomplete cycling,
and error handling, plus functional tests for /ai command dispatch.
A stub_ai.c module isolates unit tests from UI dependencies.
2026-05-15 02:21:54 +00:00

630 lines
18 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 <time.h>
#include <stabber.h>
#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)
{
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);
}
}
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 int
_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;
}
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 = 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(&regex, 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(&regex, output_buffer, 0, NULL, 0);
if (ret == 0) {
regfree(&regex);
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(&regex);
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 = 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 (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 = EXPECT_TIMEOUT_CONNECT;
}
void
prof_connect(void)
{
prof_connect_with_roster(
"<item jid='buddy1@localhost' subscription='both' name='Buddy1'/>"
"<item jid='buddy2@localhost' subscription='both' name='Buddy2'/>");
}