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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,27 @@ permissions:
contents: read

jobs:
runtime-contracts:
name: Lua runtime contracts
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: ./.github/actions/setup-wippy
id: wippy
- name: Test real runtime contracts
env:
WIPPY: ${{ steps.wippy.outputs.path }}
RUNTIME_WORK_DIR: ${{ runner.temp }}/framework-runtime
run: make test-runtime
test-runner:
name: Test runner regressions
runs-on: ubuntu-latest
timeout-minutes: 10
env:
WIPPY_FRAMEWORK_ROOT: ${{ github.workspace }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
Expand All @@ -44,6 +61,7 @@ jobs:
run: |
(cd src/test && wippy install && wippy lint --level error && wippy test)
(cd test-harness/runner && wippy test)
make test-runner-go
bash test-harness/runner-results/verify.sh
bash test-harness/runner-hook-failure/verify.sh
bash test-harness/runner-timeout/verify-timeouts.sh
Expand Down
29 changes: 21 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Wippy Framework
# Modules that have test directories with wippy.lock
TEST_MODULES = actor agent bootloader embeddings facade llm migration relay usage views
WIPPY ?= wippy
RUN_WIPPY = WIPPY="$(WIPPY)" bash scripts/run-wippy.sh

.PHONY: help check-manifests run-tests run-lint install
.PHONY: help check-manifests run-tests run-lint install test-runtime

help:
@echo "Wippy Framework"
Expand All @@ -12,6 +14,18 @@ help:
@echo " make run-lint Run lint for all modules"
@echo " make check-manifests Validate package and test-app module types"
@echo " make install Install dependencies for all test modules"
@echo " make test-runtime Test real process and control delivery"

test-runtime:
WIPPY_TEST_REQUIRE_CASES=1 $(RUN_WIPPY) src/actor/test test test -- actor_runtime_test
WIPPY_TEST_REQUIRE_CASES=1 $(RUN_WIPPY) src/actor/test test test -- actor_runtime_resident_test
WIPPY_TEST_REQUIRE_CASES=1 $(RUN_WIPPY) src/agent/test test \
-o wippy.llm:process_host:default=wippy.terminal:host \
-o wippy.llm:env_storage:default=app:env_storage test -- tools_controls_runtime_test
WIPPY_TEST_REQUIRE_CASES=1 $(RUN_WIPPY) src/relay/test test \
-o wippy.relay:application_host:default=app:processes \
-o wippy.relay:user_security_scope:default=app:user test -- relay_runtime_test
WIPPY_TEST_REQUIRE_CASES=1 $(RUN_WIPPY) test-harness/runner test test -- aggregate_checks_test

check-manifests:
@python3 scripts/check_module_manifests.py
Expand All @@ -20,8 +34,7 @@ run-tests:
@failed=0; \
for mod in $(TEST_MODULES); do \
printf "%-14s " "$$mod"; \
output=$$($(MAKE) -s -C src/$$mod/test test 2>&1); \
if echo "$$output" | grep -q "PASSED"; then \
if output=$$($(MAKE) -s -C src/$$mod/test test 2>&1); then \
echo "PASSED"; \
else \
echo "FAILED"; \
Expand All @@ -36,12 +49,12 @@ run-lint:
@failed=0; \
for mod in $(TEST_MODULES); do \
printf "%-14s " "$$mod"; \
output=$$($(MAKE) -s -C src/$$mod/test lint 2>&1); \
if echo "$$output" | grep -q "errors"; then \
echo "$$output" | grep -oP 'Checked.*'; \
failed=1; \
if output=$$($(MAKE) -s -C src/$$mod/test lint 2>&1); then \
echo "PASSED"; \
else \
echo "$$output" | grep -oP 'Checked.*'; \
echo "FAILED"; \
echo "$$output" | tail -5; \
failed=1; \
fi; \
done; \
if [ $$failed -eq 1 ]; then echo ""; echo "Lint errors found"; exit 1; fi; \
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ and configuration management — enabling developers to build sophisticated mult
| **stream** | Data streaming interface with chunked reading support |
| **cloudstorage** | Cloud storage provider interface for S3-like operations |

## Runtime regression tests

`make test-runtime` exercises actor messages, relay delivery, tool controls, and aggregate
runner checks through Wippy using the local component and test-runner sources. Required
suites fail if they execute no cases.

Set `WIPPY` to select the executable and `RUNTIME_WORK_DIR` to reuse an external dependency
directory. Test data stays outside the repository.

[module-actor]: https://github.com/wippyai/module-actor
[module-agent]: https://github.com/wippyai/module-agent
[module-bootloader]: https://github.com/wippyai/module-bootloader
Expand Down
51 changes: 51 additions & 0 deletions scripts/run-wippy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail

repo=$(cd "$(dirname "$0")/.." && pwd -P)
source_root=$repo
app=$1
shift
wippy=$(command -v "${WIPPY:-wippy}")
work_root=${RUNTIME_WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/wippy-framework.XXXXXX")}
mkdir -p "$work_root"
work_root=$(cd "$work_root" && pwd -P)
case "$work_root/" in "$repo/"*) echo 'Runtime files must be outside the repository' >&2; exit 1;; esac
if [ -z "${RUNTIME_WORK_DIR:-}" ]; then
trap 'rm -r -- "$work_root"' EXIT
fi

app_root=$(cd "$source_root/$app" && pwd)
work="$work_root/${app//\//-}"
mkdir -p "$work"
source_path=$(awk '$1 == "src:" {print $2; exit}' "$app_root/wippy.lock")
source_path=$(cd "$app_root/${source_path:-.}" && pwd)
{
printf 'directories:\n modules: %s\n src: %s\n' \
"$(jq -Rn --arg value "$work/modules" '$value')" \
"$(jq -Rn --arg value "$source_path" '$value')"
awk '/^modules:/ {active=1} /^replacements:/ {active=0} active {print}' "$app_root/wippy.lock"
} > "$work/wippy.lock"
jq -n --arg root "$source_root" --arg work "$work" '{
version: "1.0",
registry: {dependency_vendor_dir: ($work + "/modules/vendor"), dependency_lock_path: ($work + "/wippy.lock")},
workspace: {replacements: {"wippy/test": ($root + "/src/test")}}
}' > "$work/.wippy.yaml"

while IFS=$'\t' read -r component replacement; do
replacement=$(cd "$app_root/$replacement" && pwd)
jq --arg component "$component" --arg path "$replacement" \
'.workspace.replacements[$component] = $path' "$work/.wippy.yaml" > "$work/config.next"
mv "$work/config.next" "$work/.wippy.yaml"
done < <(awk '/^replacements:/ {active=1} active && $2 == "from:" {module=$3} active && $1 == "to:" {printf "%s\t%s\n", module, $2}' "$app_root/wippy.lock")

if [ "$app" = src/facade/test ]; then
jq --arg path "$source_root/src/facade" '.workspace.replacements["wippy/facade"] = $path' \
"$work/.wippy.yaml" > "$work/config.next"
mv "$work/config.next" "$work/.wippy.yaml"
fi

export WIPPY_FRAMEWORK_ROOT="$repo"
export WIPPY_TEST_ARTIFACTS="$work"
cd "$work"
"$wippy" install
"$wippy" "$@"
24 changes: 9 additions & 15 deletions src/actor/src/actor.lua
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,7 @@ local function get_process(): any
return actor._process
end

return {
inbox = function() return process.inbox() end,
events = function() return process.events() end,
send = function(dest, topic, payload) return process.send(dest, topic, payload) end,
pid = function() return process.pid() end,
event = process.event
}
return process
end

function actor.new(initial_state: any, handlers: any): any
Expand Down Expand Up @@ -108,15 +102,15 @@ function actor.new(initial_state: any, handlers: any): any
internal_channel:case_receive()
}

-- Update in place: go-lua detaches this upvalue from run_loop after a caught error.
local function rebuild_select_cases()
select_cases = {
inbox:case_receive(),
events:case_receive(),
internal_channel:case_receive()
}

local count = 3
for _, channel_info in pairs(registered_channels) do
table.insert(select_cases, channel_info.chan:case_receive())
count = count + 1
select_cases[count] = channel_info.chan:case_receive()
end
for index = #select_cases, count + 1, -1 do
select_cases[index] = nil
end
end

Expand Down Expand Up @@ -277,7 +271,7 @@ function actor.new(initial_state: any, handlers: any): any
-- Main actor loop
while true do
local result: SelectResult = channel.select(select_cases)
if not result.ok then
if not result.ok and not channel_to_id[result.channel] then
break
end

Expand Down
4 changes: 4 additions & 0 deletions src/actor/test/.wippy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: "1.0"
workspace:
replacements:
wippy/test: ../../test
51 changes: 51 additions & 0 deletions src/actor/test/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,54 @@ entries:
- time
- json
method: run

- name: runtime_processes
kind: process.host
lifecycle:
auto_start: true

- name: actor_runtime_fixture
kind: process.lua
source: file://actor_runtime_fixture.lua
imports:
actor: wippy.actor:actor
modules:
- time
method: run

- name: actor_runtime_test
kind: function.lua
meta:
type: test
suite: actor-runtime
timeout: 20s
source: file://actor_runtime_test.lua
imports:
actor: wippy.actor:actor
test: wippy.test:test
modules:
- system
- time
method: run

- name: actor_resident_fixture
kind: process.lua
source: file://actor_runtime_fixture.lua
imports:
actor: wippy.actor:actor
modules:
- time
method: resident

- name: actor_runtime_resident_test
kind: function.lua
meta:
type: test
suite: actor-runtime
timeout: 25s
source: file://actor_resident_test.lua
imports:
test: wippy.test:test
modules:
- time
method: run
106 changes: 106 additions & 0 deletions src/actor/test/actor_resident_test.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
local test = require("test")
local time = require("time")

local sequence = 0

local function with_residents(count: number, body: any): any
sequence = sequence + 1
local topic = "actor.resident." .. sequence
local responses = process.listen(topic)
local events = process.events()
local active = {}
local pids = {}
local batch = 0
local pending_deadline
local function dispatch()
batch = batch + 1
for index, pid in ipairs(pids) do
assert(process.send(pid, "dispatch", { index = index, batch = batch }))
end
local seen = {}
local deadline = time.timer(10 * time.SECOND)
pending_deadline = deadline
for _ = 1, count do
local selected = channel.select({ responses:case_receive(), deadline:channel():case_receive() })
assert(selected.channel == responses and selected.ok, "resident actor response deadline")
local value = selected.value
assert(active[value.pid] and not seen[value.pid], "resident actor response identity mismatch")
assert(pids[value.value.index] == value.pid and value.value.batch == batch and value.count == batch,
"resident actor dispatch mismatch")
seen[value.pid] = true
end
deadline:stop()
pending_deadline = nil
end
local ok: boolean, result: any = pcall(function()
for index = 1, count do
local pid, err = process.spawn_monitored("app:actor_resident_fixture", "app:runtime_processes", {
parent = process.pid(), topic = topic,
})
assert(pid, tostring(err))
active[pid] = true
pids[index] = pid
end
local ready = {}
local deadline = time.timer(10 * time.SECOND)
pending_deadline = deadline
for _ = 1, count do
local selected = channel.select({ responses:case_receive(), deadline:channel():case_receive() })
assert(selected.channel == responses and selected.ok, "resident actor startup deadline")
local value = selected.value
assert(value.phase == "ready" and active[value.pid] and not ready[value.pid],
"resident actor startup identity mismatch")
ready[value.pid] = true
end
deadline:stop()
pending_deadline = nil
return body(dispatch)
end)
if pending_deadline then pending_deadline:stop() end
for pid in pairs(active) do process.cancel(pid, "2s") end
local deadline = time.timer(10 * time.SECOND)
while next(active) do
local selected = channel.select({ events:case_receive(), deadline:channel():case_receive() })
if selected.channel ~= events then break end
local event = selected.value
if active[event.from] and event.kind == process.event.EXIT then
active[event.from] = nil
process.unmonitor(event.from)
if event.result.error then ok, result = false, tostring(event.result.error) end
end
end
deadline:stop()
local cleanup_expired = next(active) ~= nil
if cleanup_expired then
for pid in pairs(active) do process.terminate(pid) end
local forced = time.timer(3 * time.SECOND)
while next(active) do
local selected = channel.select({ events:case_receive(), forced:channel():case_receive() })
if selected.channel ~= events then break end
local event = selected.value
if active[event.from] and event.kind == process.event.EXIT then
active[event.from] = nil
process.unmonitor(event.from)
end
end
forced:stop()
end
process.unlisten(responses)
assert(not next(active), tostring(result or "") .. "; resident actor termination deadline")
if not ok then error(result) end
assert(not cleanup_expired, "resident actor cleanup deadline")
return result
end

local function define_tests()
test.describe("resident actor runtime", function()
test.it("starts, dispatches to, and cancels a real actor cohort", function()
with_residents(4, function(dispatch)
dispatch()
dispatch()
end)
end)
end)
end

return { run = test.run_cases(define_tests) }
Loading
Loading