From 7f7209c795f6433ba8bf0fb8b73a18be15e088c6 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Mon, 8 Jun 2026 04:13:22 +0200 Subject: [PATCH] test(craftos): add eventloop harness coverage --- .env.sample | 1 + .gitignore | 1 + Justfile | 41 +++++++- tests/eventloop.lua | 229 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 .env.sample create mode 100644 tests/eventloop.lua diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..b9a8c81 --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +TRAP_CCLIBS_TEST_TIMEOUT_SECONDS=3 diff --git a/.gitignore b/.gitignore index af82f2d..9c70447 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .craftos +.env diff --git a/Justfile b/Justfile index d6f7ac8..d384b50 100644 --- a/Justfile +++ b/Justfile @@ -6,7 +6,14 @@ default: @just --list # Install local development tooling. -install: install-git-hooks check-install +install: init-env install-git-hooks check-install + +# Create a local environment file when one does not exist. +init-env: + @if [ ! -f .env ]; then \ + cp .env.sample .env; \ + printf '%s\n' 'Created .env from .env.sample'; \ + fi # Install Git hooks for this repository. install-git-hooks: @@ -96,18 +103,42 @@ ci *args: check-craftos check # Run CraftOS-PC headless smoke tests. Pass `--verbose` to list each test. test *args: - @verbose=0; \ + @if [ -f .env ]; then set -a; . ./.env; set +a; fi; \ + verbose=0; \ + timeout_seconds="${TRAP_CCLIBS_TEST_TIMEOUT_SECONDS:-3}"; \ + case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_TEST_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac; \ + if [ "$timeout_seconds" -lt 1 ]; then printf '%s\n' 'TRAP_CCLIBS_TEST_TIMEOUT_SECONDS must be >= 1' >&2; exit 1; fi; \ for a in {{args}}; do [ "$a" = "--verbose" ] && verbose=1; done; \ + script_args=""; \ + if [ "$verbose" -eq 1 ]; then script_args="--verbose"; fi; \ rom_arg=""; \ if [ "$(uname -s)" = "Darwin" ]; then \ rom_arg="--rom /Applications/CraftOS-PC.app/Contents/Resources"; \ fi; \ + repo='{{justfile_directory()}}'; \ + mount_arg="--mount-ro /apis=$repo/apis"; \ green=$(printf '\033[32m'); reset=$(printf '\033[0m'); red=$(printf '\033[31m'); \ - for script in tests/boot.lua tests/ready.lua; do \ - if craftos --headless $rom_arg --script "$script" | grep -q __READY__; then \ + for script in tests/boot.lua tests/ready.lua tests/eventloop.lua; do \ + tmp="$(mktemp)"; \ + craftos --headless $rom_arg $mount_arg --script "$script" $script_args >"$tmp" 2>&1 & \ + pid="$!"; \ + ( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) & \ + watchdog="$!"; \ + wait "$pid" >/dev/null 2>&1; \ + status="$?"; \ + kill "$watchdog" >/dev/null 2>&1 || true; \ + wait "$watchdog" >/dev/null 2>&1 || true; \ + if grep -q __READY__ "$tmp"; then \ + rm -f "$tmp"; \ [ "$verbose" -eq 1 ] && printf '%s\n' "${green}PASS${reset} $script"; \ else \ - printf '%s\n' "${red}FAIL${reset} $script did not print __READY__" >&2; \ + if [ "$status" -eq 143 ]; then \ + printf '%s\n' "${red}FAIL${reset} $script timed out after ${timeout_seconds}s" >&2; \ + else \ + printf '%s\n' "${red}FAIL${reset} $script did not print __READY__" >&2; \ + fi; \ + cat "$tmp" >&2; \ + rm -f "$tmp"; \ exit 1; \ fi; \ done; \ diff --git a/tests/eventloop.lua b/tests/eventloop.lua new file mode 100644 index 0000000..a3e5912 --- /dev/null +++ b/tests/eventloop.lua @@ -0,0 +1,229 @@ +-- Eventloop behavior tests for the CraftOS-PC harness. +-- Invoked via `craftos --headless --script tests/eventloop.lua` from `just test`. +local createEventLoop = require('/apis/eventloop'); + +local args = { ... }; +local verbose = false; +local tests = {}; + +for _, arg in ipairs(args) do + if arg == '--verbose' then + verbose = true; + end +end + +local function test(name, fn) + tests[#tests + 1] = { name = name, fn = fn }; +end + +local function fail(message) + error(message, 2); +end + +local function assertEquals(actual, expected, message) + if actual ~= expected then + fail((message or 'assertEquals failed') .. ': expected ' .. tostring(expected) .. ', got ' .. tostring(actual)); + end +end + +local function assertTrue(value, message) + if not value then + fail(message or 'assertTrue failed'); + end +end + +local function assertErrors(fn, expected) + local ok, err = pcall(fn); + if ok then + fail('expected error'); + end + if expected and not string.find(tostring(err), expected, 1, true) then + fail('expected error containing ' .. expected .. ', got ' .. tostring(err)); + end +end + +test('register dispatches queued event args', function() + local events = createEventLoop(); + local called = 0; + + events.register('eventloop_test_basic', function(a, b) + called = called + 1; + assertEquals(a, 'first'); + assertEquals(b, 42); + events.stopLoop(); + end); + + os.queueEvent('eventloop_test_basic', 'first', 42); + events.runLoop(); + + assertEquals(called, 1); + assertTrue(not events.isRunningLoop()); +end); + +test('STOP unregisters handler after first call', function() + local events = createEventLoop(); + local stoppedHandlerCalls = 0; + local observerCalls = 0; + + events.register('eventloop_test_stop', function() + stoppedHandlerCalls = stoppedHandlerCalls + 1; + return events.STOP; + end); + + events.register('eventloop_test_stop', function() + observerCalls = observerCalls + 1; + if observerCalls == 1 then + os.queueEvent('eventloop_test_stop'); + else + events.stopLoop(); + end + end); + + os.queueEvent('eventloop_test_stop'); + events.runLoop(); + + assertEquals(stoppedHandlerCalls, 1); + assertEquals(observerCalls, 2); +end); + +test('manual unregister prevents dispatch', function() + local events = createEventLoop(); + local called = 0; + local dispose = events.register('eventloop_test_unregister', function() + called = called + 1; + end); + + events.setTimeout(function() + events.stopLoop(); + end, 0); + dispose(); + + os.queueEvent('eventloop_test_unregister'); + events.runLoop(); + + assertEquals(called, 0); +end); + +test('setTimeout before runLoop fires once', function() + local events = createEventLoop(); + local called = 0; + + events.setTimeout(function() + called = called + 1; + events.stopLoop(); + end, 0); + + events.runLoop(); + + assertEquals(called, 1); +end); + +test('setTimeout during runLoop fires after event handler', function() + local events = createEventLoop(); + local order = ''; + + events.register('eventloop_test_runtime_timeout', function() + order = order .. 'event>'; + events.setTimeout(function() + order = order .. 'timeout'; + events.stopLoop(); + end, 0); + return events.STOP; + end); + + os.queueEvent('eventloop_test_runtime_timeout'); + events.runLoop(); + + assertEquals(order, 'event>timeout'); +end); + +test('cleared timeout before runLoop does not fire', function() + local events = createEventLoop(); + local called = 0; + + local clear = events.setTimeout(function() + called = called + 1; + end, 0); + clear(); + + events.setTimeout(function() + events.stopLoop(); + end, 0); + events.runLoop(); + + assertEquals(called, 0); +end); + +test('onStart and onStop run around loop', function() + local events = createEventLoop(); + local order = ''; + + events.onStart(function() + order = order .. 'start>'; + end); + + events.onStop(function() + order = order .. 'stop'; + end); + + events.setTimeout(function() + order = order .. 'timeout>'; + events.stopLoop(); + end, 0); + + events.runLoop(); + + assertEquals(order, 'start>timeout>stop'); +end); + +test('empty loop returns and runs onStop', function() + local events = createEventLoop(); + local stopped = false; + + events.onStop(function() + stopped = true; + end); + + events.runLoop(); + + assertTrue(stopped); + assertTrue(not events.isRunningLoop()); +end); + +test('error contracts are enforced', function() + local events = createEventLoop(); + local function handler() + end + + events.register('eventloop_test_errors', handler); + + assertErrors(function() + events.register('eventloop_test_errors', handler); + end, 'handler already registered'); + + assertErrors(function() + events.stopLoop(); + end, 'loop is already stopped'); + + assertErrors(function() + events.register(1, handler); + end, 'string expected'); + + assertErrors(function() + events.register('eventloop_test_errors_2', 'not a function'); + end, 'function expected'); +end); + +for _, t in ipairs(tests) do + if verbose then + print('RUN ' .. t.name); + end + local ok, err = pcall(t.fn); + if not ok then + print('FAIL ' .. t.name .. ': ' .. tostring(err)); + os.shutdown(); + end +end + +print('__READY__'); +os.shutdown();