Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ jobs:
- name: posix
platform: POSIX
host_sim: OFF
ctest_regex: e2e|telnet-io
ctest_regex: e2e|unit|baremetal-hal|telnet-io
- name: host-sim
platform: POSIX
host_sim: ON
ctest_regex: e2e|host-sim|telnet-io
ctest_regex: e2e|unit|baremetal-hal|host-sim|telnet-io
- name: baremetal
platform: BAREMETAL
host_sim: OFF
Expand Down
21 changes: 21 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,25 @@ if(EC_PLATFORM STREQUAL "POSIX")
target_include_directories(embedclaw_telnet_tests PRIVATE include tests)
target_compile_definitions(embedclaw_telnet_tests PRIVATE EC_CONFIG_TELNET_PORT=24232)

add_executable(embedclaw_unit_tests
src/core/ec_json.c
src/core/ec_session.c
tests/test_unit.c
)
target_include_directories(embedclaw_unit_tests PRIVATE include tests)

add_executable(embedclaw_baremetal_hal_tests
src/core/ec_io.c
src/platform/baremetal/ec_io_baremetal_uart.c
src/platform/baremetal/ec_socket_baremetal.c
tests/test_baremetal_hal.c
)
target_include_directories(embedclaw_baremetal_hal_tests PRIVATE include tests)
target_compile_definitions(embedclaw_baremetal_hal_tests PRIVATE
EC_PLATFORM_BAREMETAL
EC_CONFIG_USE_TLS=0
)

if(EC_HOST_SIM)
add_executable(embedclaw_host_sim_tests
${EC_SOURCES_NO_HTTP}
Expand All @@ -190,6 +209,8 @@ if(EC_PLATFORM STREQUAL "POSIX")

enable_testing()
add_test(NAME e2e COMMAND embedclaw_tests)
add_test(NAME unit COMMAND embedclaw_unit_tests)
add_test(NAME baremetal-hal COMMAND embedclaw_baremetal_hal_tests)
add_test(NAME telnet-io COMMAND embedclaw_telnet_tests)
if(EC_HOST_SIM)
add_test(NAME host-sim COMMAND embedclaw_host_sim_tests)
Expand Down
193 changes: 193 additions & 0 deletions tests/test_baremetal_hal.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#include "test_runner.h"
#include "ec_io.h"
#include "ec_socket.h"

#include <stdint.h>
#include <string.h>

typedef struct {
const char *input;
size_t input_pos;
char output[128];
size_t output_len;
} fake_uart_t;

static int fake_uart_read(void *buf, size_t len, fake_uart_t *uart)
{
(void)len;
if (!uart->input[uart->input_pos]) return 0;
*(char *)buf = uart->input[uart->input_pos++];
return 1;
}

static int fake_uart_write(const void *buf, size_t len, fake_uart_t *uart)
{
if (uart->output_len + len >= sizeof(uart->output)) return -1;
memcpy(uart->output + uart->output_len, buf, len);
uart->output_len += len;
uart->output[uart->output_len] = '\0';
return (int)len;
}

static fake_uart_t *s_uart_ctx;

static int uart_read_wrapper(void *buf, size_t len, uint32_t timeout_ms)
{
(void)timeout_ms;
return fake_uart_read(buf, len, s_uart_ctx);
}

static int uart_write_wrapper(const void *buf, size_t len, uint32_t timeout_ms)
{
(void)timeout_ms;
return fake_uart_write(buf, len, s_uart_ctx);
}

typedef struct {
int connect_calls;
int send_calls;
int recv_calls;
int close_calls;
const char *host;
uint16_t port;
int use_tls;
char sent[64];
const char *recv_data;
} fake_socket_t;

static int fake_connect(void *ctx, const char *host, uint16_t port, int use_tls)
{
fake_socket_t *sock = (fake_socket_t *)ctx;
sock->connect_calls++;
sock->host = host;
sock->port = port;
sock->use_tls = use_tls;
return 0;
}

static int fake_send(void *ctx, const void *data, size_t len)
{
fake_socket_t *sock = (fake_socket_t *)ctx;
sock->send_calls++;
if (len >= sizeof(sock->sent)) return -1;
memcpy(sock->sent, data, len);
sock->sent[len] = '\0';
return (int)len;
}

static int fake_recv(void *ctx, void *buf, size_t len, uint32_t timeout_ms)
{
fake_socket_t *sock = (fake_socket_t *)ctx;
(void)timeout_ms;
sock->recv_calls++;
size_t n = strlen(sock->recv_data);
if (n > len) n = len;
memcpy(buf, sock->recv_data, n);
return (int)n;
}

static void fake_close(void *ctx)
{
fake_socket_t *sock = (fake_socket_t *)ctx;
sock->close_calls++;
}

static int test_baremetal_uart_reads_lines_and_writes(void)
{
fake_uart_t uart;
memset(&uart, 0, sizeof(uart));
uart.input = "hello\r\n";
s_uart_ctx = &uart;

ec_io_uart_hal_t hal = {
.read = uart_read_wrapper,
.write = uart_write_wrapper,
};
ec_io_uart_set_hal(&hal);
ec_io_init(&ec_io_uart_ops);

char line[16];
ASSERT_EQ(ec_io_read_line(line, sizeof(line)), 5,
"bare-metal UART should read a line");
ASSERT_EQ(strcmp(line, "hello"), 0, "bare-metal UART should strip CRLF");
ASSERT_EQ(ec_io_write("ok"), 0, "bare-metal UART should write");
ASSERT_EQ(strcmp(uart.output, "ok"), 0, "write should reach HAL");
return 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The UART HAL and context pointers are stored in global variables (s_uart_hal and s_uart_ctx) but point to local stack variables within this test. To prevent dangling pointers that could cause issues if more tests are added or reordered, these globals should be cleared before the function returns.

    ec_io_uart_set_hal(NULL);
    s_uart_ctx = NULL;
    return 1;

}

static int test_baremetal_uart_truncates_long_lines(void)
{
fake_uart_t uart;
memset(&uart, 0, sizeof(uart));
uart.input = "abcdef\n";
s_uart_ctx = &uart;

ec_io_uart_hal_t hal = {
.read = uart_read_wrapper,
.write = uart_write_wrapper,
};
ec_io_uart_set_hal(&hal);
ec_io_init(&ec_io_uart_ops);

char line[4];
ASSERT_EQ(ec_io_read_line(line, sizeof(line)), 0,
"truncated line should return an empty command");
ASSERT_EQ(line[0], '\0', "truncated line should clear output buffer");
ASSERT_STR(uart.output, "input truncated",
"truncation warning should be written through HAL");
return 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous test, the global HAL and context pointers should be cleared to avoid leaving the test suite in an inconsistent state with dangling pointers.

    ec_io_uart_set_hal(NULL);
    s_uart_ctx = NULL;
    return 1;

}

static int test_baremetal_socket_delegates_to_hal(void)
{
fake_socket_t fake;
memset(&fake, 0, sizeof(fake));
fake.recv_data = "HTTP/1.1 200 OK\r\n\r\n{}";

ec_socket_baremetal_hal_t hal = {
.ctx = &fake,
.connect = fake_connect,
.send = fake_send,
.recv = fake_recv,
.close = fake_close,
};
ec_socket_baremetal_set_hal(&hal);

ec_socket_t *sock = ec_socket_connect("api.example.test", 443, 1);
ASSERT(sock != 0, "connect should return a socket handle");
ASSERT_EQ(fake.connect_calls, 1, "connect callback should run");
ASSERT_EQ(strcmp(fake.host, "api.example.test"), 0, "host should be passed through");
ASSERT_EQ(fake.port, 443, "port should be passed through");
ASSERT_EQ(fake.use_tls, 1, "TLS flag should be passed through");

ASSERT(ec_socket_connect("second.example.test", 80, 0) == 0,
"single static bare-metal socket should reject concurrent connect");

ASSERT_EQ(ec_socket_send(sock, "ping", 4), 4, "send should delegate to HAL");
ASSERT_EQ(strcmp(fake.sent, "ping"), 0, "send payload should reach HAL");

char buf[32];
int n = ec_socket_recv(sock, buf, sizeof(buf), 100);
ASSERT(n > 0, "recv should delegate to HAL");
buf[n] = '\0';
Comment on lines +170 to +172
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Potential buffer overflow: if ec_socket_recv returns the full size of the buffer (32), the subsequent null-termination buf[n] = '\0' will write one byte past the end of the array. You should pass sizeof(buf) - 1 to the receive function to ensure space for the terminator.

Suggested change
int n = ec_socket_recv(sock, buf, sizeof(buf), 100);
ASSERT(n > 0, "recv should delegate to HAL");
buf[n] = '\0';
int n = ec_socket_recv(sock, buf, sizeof(buf) - 1, 100);
ASSERT(n > 0, "recv should delegate to HAL");
buf[n] = '\0';

ASSERT_STR(buf, "HTTP/1.1 200 OK", "recv payload should come from HAL");

ec_socket_close(sock);
ASSERT_EQ(fake.close_calls, 1, "close callback should run");
ASSERT(ec_socket_connect("after-close.example.test", 80, 0) != 0,
"connect should work again after close");
ec_socket_close(sock);
return 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The socket HAL pointer should be cleared before the test returns to prevent other tests from accidentally using a dangling pointer to this function's stack.

    ec_socket_baremetal_set_hal(NULL);
    return 1;

}

int main(void)
{
printf("=== EmbedClaw Bare-metal HAL Tests ===\n\n");

RUN_TEST(test_baremetal_uart_reads_lines_and_writes);
RUN_TEST(test_baremetal_uart_truncates_long_lines);
RUN_TEST(test_baremetal_socket_delegates_to_hal);

PRINT_RESULTS();
return (_tests_pass == _tests_run) ? 0 : 1;
}
147 changes: 147 additions & 0 deletions tests/test_unit.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#include "test_runner.h"
#include "ec_json.h"
#include "ec_session.h"

#include <string.h>

static int test_json_writer_escapes_and_composes(void)
{
char buf[256];
ec_json_writer_t w;

ec_json_writer_init(&w, buf, sizeof(buf));
ec_json_obj_start(&w);
ec_json_add_string(&w, "text", "quote: \" slash: \\ newline:\n");
ec_json_add_int(&w, "count", 3);
ec_json_array_start(&w, "items");
ec_json_array_obj_start(&w);
ec_json_add_string(&w, "name", "uart0");
ec_json_obj_end(&w);
ec_json_array_end(&w);
ec_json_obj_end(&w);

ASSERT(ec_json_writer_finish(&w) > 0, "writer should finish");
ASSERT_STR(buf, "\"text\":\"quote: \\\" slash: \\\\ newline:\\n\"",
"writer should escape special characters");
ASSERT_STR(buf, "\"count\":3", "writer should include integer fields");
ASSERT_STR(buf, "\"items\":[{\"name\":\"uart0\"}]",
"writer should compose arrays of objects");
return 1;
}

static int test_json_writer_reports_overflow(void)
{
char buf[24];
ec_json_writer_t w;

ec_json_writer_init(&w, buf, sizeof(buf));
ec_json_obj_start(&w);
ec_json_add_string(&w, "too_long", "abcdefghijklmnopqrstuvwxyz");
ec_json_obj_end(&w);

ASSERT_EQ(ec_json_writer_finish(&w), -1, "small buffer should overflow");
return 1;
}

static int test_json_find_nested_strings_and_bounds(void)
{
const char json[] =
"{\"choices\":[{\"message\":{\"content\":\"hello\","
"\"tool_calls\":[{\"function\":{\"name\":\"hw_register_read\"}}]}}]}";
char out[32];

ASSERT_EQ(ec_json_find_string(json, strlen(json),
"choices[0].message.content",
out, sizeof(out)), 5,
"nested string lookup should return length");
ASSERT_EQ(strcmp(out, "hello"), 0, "nested string lookup should copy value");

ASSERT(ec_json_find_string(json, strlen(json),
"choices[0].message.tool_calls[0].function.name",
out, sizeof(out)) > 0,
"array path lookup should succeed");
ASSERT_EQ(strcmp(out, "hw_register_read"), 0,
"array path lookup should copy tool name");

ASSERT(ec_json_find_string(json, strlen(json),
"choices[1].message.content",
out, sizeof(out)) < 0,
"out-of-range array lookup should fail");
memset(out, 'x', sizeof(out));
ASSERT_EQ(ec_json_find_string(json, strlen(json),
"choices[0].message.content",
out, 4), 5,
"undersized output buffer should still report full value length");
ASSERT_EQ(strcmp(out, "hel"), 0,
"undersized output buffer should contain a safe truncated string");
return 1;
}

static int test_session_message_view_and_reset(void)
{
ec_session_t session;
ec_model_tool_call_t call;
size_t count = 0;

memset(&call, 0, sizeof(call));
strcpy(call.id, "call_1");
strcpy(call.name, "hw_register_read");
strcpy(call.arguments, "{\"address\":\"0x40000000\"}");

ec_session_init(&session, "system prompt");
ASSERT_EQ(ec_session_append(&session, "user", "hello"), 0,
"user append should succeed");
ASSERT_EQ(ec_session_append_tool_calls(&session, &call, 1), 0,
"assistant tool-call append should succeed");
ASSERT_EQ(ec_session_append_tool_result(&session, "call_1",
"{\"value\":\"0x00000000\"}"), 0,
"tool result append should succeed");

const ec_model_message_t *messages = ec_session_messages(&session, &count);
ASSERT_EQ(count, 4, "message view should include system plus three entries");
ASSERT_EQ(strcmp(messages[0].role, "system"), 0, "first message is system");
ASSERT_EQ(strcmp(messages[1].role, "user"), 0, "second message is user");
ASSERT_EQ(strcmp(messages[2].role, "assistant"), 0,
"third message is assistant tool call");
ASSERT_EQ(messages[2].num_tool_calls, 1, "assistant view should expose tool call");
ASSERT_EQ(strcmp(messages[2].tool_calls[0].id, "call_1"), 0,
"tool call id should be preserved");
ASSERT_EQ(strcmp(messages[3].role, "tool"), 0, "fourth message is tool result");
ASSERT_EQ(strcmp(messages[3].tool_call_id, "call_1"), 0,
"tool result call id should be preserved");

ec_session_reset(&session);
messages = ec_session_messages(&session, &count);
ASSERT_EQ(count, 1, "reset should retain only system prompt");
ASSERT_EQ(strcmp(messages[0].content, "system prompt"), 0,
"system prompt should survive reset");
return 1;
}

static int test_session_rejects_overflow(void)
{
ec_session_t session;
ec_session_init(&session, "system prompt");

for (int i = 0; i < EC_CONFIG_MAX_HISTORY; i++) {
ASSERT_EQ(ec_session_append(&session, "user", "x"), 0,
"append before max history should succeed");
}
ASSERT_EQ(ec_session_append(&session, "user", "overflow"), -1,
"append past max history should fail");
return 1;
}

int main(void)
{
printf("=== EmbedClaw Unit Tests ===\n\n");

RUN_TEST(test_json_writer_escapes_and_composes);
RUN_TEST(test_json_writer_reports_overflow);
RUN_TEST(test_json_find_nested_strings_and_bounds);
RUN_TEST(test_session_message_view_and_reset);
RUN_TEST(test_session_rejects_overflow);

PRINT_RESULTS();
return (_tests_pass == _tests_run) ? 0 : 1;
}
Loading