From d4e58d24e75c37afe84e459723144b856282fe6e Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Thu, 9 Jul 2026 21:24:42 -0400 Subject: [PATCH 1/5] fix(fs): model readdir iterator --- runtime/lua/modules/fs/types.go | 14 +++++++- runtime/lua/modules/fs/types_test.go | 54 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 runtime/lua/modules/fs/types_test.go diff --git a/runtime/lua/modules/fs/types.go b/runtime/lua/modules/fs/types.go index 3e1db97ec..5d825a1f4 100644 --- a/runtime/lua/modules/fs/types.go +++ b/runtime/lua/modules/fs/types.go @@ -30,6 +30,18 @@ var fileInfoType = typ.NewRecord(). Field("type", typ.String). Build() +// dirEntryType is the value produced by FS:readdir's generic-for iterator. +// It intentionally differs from FileInfo: readdir exposes only the entry name +// and kind, matching dirIteratorNext in fs.go. +var dirEntryType = typ.NewRecord(). + Field("name", typ.String). + Field("type", typ.String). + Build() + +var dirIteratorType = typ.Func(). + Returns(typ.NewOptional(dirEntryType)). + Build() + var scannerType = typ.NewInterface("fs.Scanner", []typ.Method{ {Name: "scan", Type: typ.Func(). Param("self", typ.Self). @@ -108,7 +120,7 @@ func init() { {Name: "readdir", Type: typ.Func(). Param("self", typ.Self). Param("path", typ.String). - Returns(typ.NewArray(fileInfoType), typ.NewOptional(typ.LuaError)). + Returns(dirIteratorType, typ.Any). Build()}, {Name: "mkdir", Type: typ.Func(). Param("self", typ.Self). diff --git a/runtime/lua/modules/fs/types_test.go b/runtime/lua/modules/fs/types_test.go new file mode 100644 index 000000000..8a7c4a7bc --- /dev/null +++ b/runtime/lua/modules/fs/types_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 + +package fs + +import ( + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +func TestReaddirTypeReturnsDirectoryEntryIterator(t *testing.T) { + manifest := ModuleTypes() + fs, ok := manifest.LookupType("FS") + if !ok { + t.Fatal("FS type is not defined") + } + + methods := fs.(*typ.Interface).Methods + var readdir *typ.Function + for _, method := range methods { + if method.Name == "readdir" { + readdir = method.Type + break + } + } + if readdir == nil { + t.Fatal("readdir method is not defined") + } + if len(readdir.Returns) != 2 { + t.Fatalf("readdir returns %d values, want iterator and state", len(readdir.Returns)) + } + + iterator, ok := readdir.Returns[0].(*typ.Function) + if !ok { + t.Fatalf("readdir iterator = %T, want function", readdir.Returns[0]) + } + if len(iterator.Returns) != 1 { + t.Fatalf("iterator returns %d values, want directory entry", len(iterator.Returns)) + } + entry, ok := iterator.Returns[0].(*typ.Optional) + if !ok { + t.Fatalf("iterator entry = %T, want optional record", iterator.Returns[0]) + } + record, ok := entry.Inner.(*typ.Record) + if !ok { + t.Fatalf("iterator entry = %T, want record", entry.Inner) + } + for _, field := range []string{"name", "type"} { + got := record.GetField(field) + if got == nil || !typ.TypeEquals(got.Type, typ.String) { + t.Fatalf("entry.%s = %v, want string", field, got) + } + } +} From 40df04d14d99b8f01e12edf6e51ecc9838e20baa Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Thu, 9 Jul 2026 21:33:38 -0400 Subject: [PATCH 2/5] fix(queue): type message headers --- runtime/lua/modules/queue/types.go | 2 +- runtime/lua/modules/queue/types_test.go | 32 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 runtime/lua/modules/queue/types_test.go diff --git a/runtime/lua/modules/queue/types.go b/runtime/lua/modules/queue/types.go index ad6022f02..7a8ddc39d 100644 --- a/runtime/lua/modules/queue/types.go +++ b/runtime/lua/modules/queue/types.go @@ -15,7 +15,7 @@ var messageType = typ.NewInterface("queue.Message", []typ.Method{ }, { Name: "header", - Type: typ.Func().Param("_", typ.Self).Param("key", typ.String).Returns(typ.NewOptional(typ.Any), typ.NewOptional(typ.LuaError)).Build(), + Type: typ.Func().Param("_", typ.Self).Param("key", typ.String).Returns(typ.String, typ.NewOptional(typ.LuaError)).Build(), }, { Name: "headers", diff --git a/runtime/lua/modules/queue/types_test.go b/runtime/lua/modules/queue/types_test.go new file mode 100644 index 000000000..f6b1998fe --- /dev/null +++ b/runtime/lua/modules/queue/types_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 + +package queue + +import ( + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +func TestMessageHeaderTypeReturnsString(t *testing.T) { + manifest := ModuleTypes() + message, ok := manifest.LookupType("Message") + if !ok { + t.Fatal("Message type is not defined") + } + + for _, method := range message.(*typ.Interface).Methods { + if method.Name != "header" { + continue + } + if len(method.Type.Returns) != 2 { + t.Fatalf("header returns %d values, want value and error", len(method.Type.Returns)) + } + if !typ.TypeEquals(method.Type.Returns[0], typ.String) { + t.Fatalf("header value = %s, want string", method.Type.Returns[0]) + } + return + } + + t.Fatal("header method is not defined") +} From 4d5e214b8607a5c47845d8057b09cc41814a7e7e Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Thu, 9 Jul 2026 21:33:38 -0400 Subject: [PATCH 3/5] fix(registry): retain module manifests --- runtime/lua/code/manager.go | 13 +++++++------ runtime/lua/code/manager_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/runtime/lua/code/manager.go b/runtime/lua/code/manager.go index b7630d575..542cebf36 100644 --- a/runtime/lua/code/manager.go +++ b/runtime/lua/code/manager.go @@ -438,12 +438,13 @@ func (cm *Manager) Compile( func (cm *Manager) AddNode(_ context.Context, node Node, deps []Import) error { // Spawn pointer from value nodePtr := &Node{ - ID: node.ID, - Kind: node.Kind, - Source: node.Source, - Method: node.Method, - Module: node.Module, - Version: cm.nextVersion(HashNode(&node)), + ID: node.ID, + Kind: node.Kind, + Source: node.Source, + Method: node.Method, + Module: node.Module, + Manifest: node.Manifest, + Version: cm.nextVersion(HashNode(&node)), } // Eager compilation check: validate source code before adding to graph diff --git a/runtime/lua/code/manager_test.go b/runtime/lua/code/manager_test.go index 278b060a6..1d1454be7 100644 --- a/runtime/lua/code/manager_test.go +++ b/runtime/lua/code/manager_test.go @@ -12,6 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" glua "github.com/wippyai/go-lua" + "github.com/wippyai/go-lua/types/io" + "github.com/wippyai/go-lua/types/typ" ctxapi "github.com/wippyai/runtime/api/context" "github.com/wippyai/runtime/api/event" "github.com/wippyai/runtime/api/registry" @@ -429,6 +431,26 @@ func TestManager_AddNode(t *testing.T) { } } +func TestManager_AddNodePreservesManifest(t *testing.T) { + cm, err := NewCodeManager(zap.NewNop(), &testEventBus{}, Config{}) + require.NoError(t, err) + + manifest := io.NewManifest("registry") + manifest.SetExport(typ.NewRecord(). + Field("get", typ.Func().Returns(typ.NewRecord().Field("id", typ.String).Build()).Build()). + Build()) + nodeID := registry.NewID("", "registry") + require.NoError(t, cm.AddNode(context.Background(), Node{ + ID: nodeID, + Kind: api.ModuleKind, + Manifest: manifest, + }, nil)) + + node, err := cm.memGraph.GetNode(nodeID) + require.NoError(t, err) + require.Same(t, manifest, node.Manifest) +} + func TestManager_UpdateNode(t *testing.T) { logger := zap.NewNop() bus := &testEventBus{} From ccadc620cc9891e93a80bf29cf36fd58e0e0670a Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 10 Jul 2026 10:00:46 -0400 Subject: [PATCH 4/5] fix(lua): align host declarations with runtime contracts --- runtime/lua/code/manager_test.go | 19 ++++- runtime/lua/modules/fs/types.go | 19 ++++- runtime/lua/modules/fs/types_test.go | 50 +++++++++++-- runtime/lua/modules/queue/module.go | 31 ++++---- runtime/lua/modules/queue/module_test.go | 71 ++++++++++++++++--- runtime/lua/modules/queue/spec.md | 18 +++-- runtime/lua/modules/queue/types.go | 4 +- runtime/lua/modules/queue/types_test.go | 53 ++++++++++---- tests/app/src/queue/task_handler.lua | 2 +- tests/app/src/test/queue/header_overrides.lua | 2 +- tests/app/src/test/queue/integration.lua | 2 +- tests/app/src/test/sqs/handler.lua | 2 +- 12 files changed, 217 insertions(+), 56 deletions(-) diff --git a/runtime/lua/code/manager_test.go b/runtime/lua/code/manager_test.go index 1d1454be7..df641a578 100644 --- a/runtime/lua/code/manager_test.go +++ b/runtime/lua/code/manager_test.go @@ -439,16 +439,33 @@ func TestManager_AddNodePreservesManifest(t *testing.T) { manifest.SetExport(typ.NewRecord(). Field("get", typ.Func().Returns(typ.NewRecord().Field("id", typ.String).Build()).Build()). Build()) - nodeID := registry.NewID("", "registry") + module := &api.ModuleDef{ + Name: "registry", + Types: func() *io.Manifest { return manifest }, + } + nodeID := registry.NewID("", module.Name) require.NoError(t, cm.AddNode(context.Background(), Node{ ID: nodeID, Kind: api.ModuleKind, + Module: module, Manifest: manifest, }, nil)) node, err := cm.memGraph.GetNode(nodeID) require.NoError(t, err) require.Same(t, manifest, node.Manifest) + + consumerID := registry.NewID("app", "consumer") + require.NoError(t, cm.AddNode(context.Background(), Node{ + ID: consumerID, + Kind: api.Function, + }, []Import{{ID: nodeID, Alias: "registry"}})) + require.Same(t, manifest, cm.GetNodeDependencyManifests(consumerID)["registry"], + "dependency aliases must retain the supplied host-module manifest") + + cm.AddBuiltinType(module) + require.NotEmpty(t, cm.BuiltinManifestHash(), + "host-module manifests must participate in the builtin cache fingerprint") } func TestManager_UpdateNode(t *testing.T) { diff --git a/runtime/lua/modules/fs/types.go b/runtime/lua/modules/fs/types.go index 5d825a1f4..cbdffb0b6 100644 --- a/runtime/lua/modules/fs/types.go +++ b/runtime/lua/modules/fs/types.go @@ -35,13 +35,25 @@ var fileInfoType = typ.NewRecord(). // and kind, matching dirIteratorNext in fs.go. var dirEntryType = typ.NewRecord(). Field("name", typ.String). - Field("type", typ.String). + Field("type", typ.NewUnion( + typ.LiteralString(typeFile), + typ.LiteralString(typeDir), + )). Build() +// Lua's generic-for protocol calls the generator as generator(state, control). +// dirIteratorNext consumes the state userdata and ignores the control value. var dirIteratorType = typ.Func(). + Param("state", typ.Any). + OptParam("control", typ.Any). Returns(typ.NewOptional(dirEntryType)). Build() +// The iterator state is opaque to Lua callers. A named, methodless interface +// is nominal in go-lua and therefore models the userdata without inventing +// operations that the runtime does not provide. +var dirIteratorStateType = typ.NewInterface("fs.DirIterator", nil) + var scannerType = typ.NewInterface("fs.Scanner", []typ.Method{ {Name: "scan", Type: typ.Func(). Param("self", typ.Self). @@ -120,7 +132,10 @@ func init() { {Name: "readdir", Type: typ.Func(). Param("self", typ.Self). Param("path", typ.String). - Returns(dirIteratorType, typ.Any). + Returns( + typ.NewOptional(dirIteratorType), + typ.NewUnion(dirIteratorStateType, typ.LuaError), + ). Build()}, {Name: "mkdir", Type: typ.Func(). Param("self", typ.Self). diff --git a/runtime/lua/modules/fs/types_test.go b/runtime/lua/modules/fs/types_test.go index 8a7c4a7bc..e953ad5ba 100644 --- a/runtime/lua/modules/fs/types_test.go +++ b/runtime/lua/modules/fs/types_test.go @@ -5,7 +5,10 @@ package fs import ( "testing" + "github.com/stretchr/testify/require" + "github.com/wippyai/go-lua/types/io" "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/runtime/runtime/lua/code" ) func TestReaddirTypeReturnsDirectoryEntryIterator(t *testing.T) { @@ -30,9 +33,16 @@ func TestReaddirTypeReturnsDirectoryEntryIterator(t *testing.T) { t.Fatalf("readdir returns %d values, want iterator and state", len(readdir.Returns)) } - iterator, ok := readdir.Returns[0].(*typ.Function) + optionalIterator, ok := readdir.Returns[0].(*typ.Optional) if !ok { - t.Fatalf("readdir iterator = %T, want function", readdir.Returns[0]) + t.Fatalf("readdir iterator = %T, want optional function", readdir.Returns[0]) + } + iterator, ok := optionalIterator.Inner.(*typ.Function) + if !ok { + t.Fatalf("readdir iterator = %T, want function", optionalIterator.Inner) + } + if len(iterator.Params) != 2 || iterator.Params[0].Name != "state" || iterator.Params[0].Optional || !iterator.Params[1].Optional { + t.Fatalf("iterator params = %+v, want required state and optional control", iterator.Params) } if len(iterator.Returns) != 1 { t.Fatalf("iterator returns %d values, want directory entry", len(iterator.Returns)) @@ -45,10 +55,36 @@ func TestReaddirTypeReturnsDirectoryEntryIterator(t *testing.T) { if !ok { t.Fatalf("iterator entry = %T, want record", entry.Inner) } - for _, field := range []string{"name", "type"} { - got := record.GetField(field) - if got == nil || !typ.TypeEquals(got.Type, typ.String) { - t.Fatalf("entry.%s = %v, want string", field, got) - } + name := record.GetField("name") + if name == nil || !typ.TypeEquals(name.Type, typ.String) { + t.Fatalf("entry.name = %v, want string", name) + } + kind := record.GetField("type") + wantKind := typ.NewUnion(typ.LiteralString(typeFile), typ.LiteralString(typeDir)) + if kind == nil || !typ.TypeEquals(kind.Type, wantKind) { + t.Fatalf("entry.type = %v, want %s", kind, wantKind) } + + wantStateOrError := typ.NewUnion(dirIteratorStateType, typ.LuaError) + if !typ.TypeEquals(readdir.Returns[1], wantStateOrError) { + t.Fatalf("readdir state/error = %s, want %s", readdir.Returns[1], wantStateOrError) + } +} + +func TestReaddirTypeChecksGenericForProtocol(t *testing.T) { + tc := code.NewTypeChecker(code.TypeCheckConfig{Enabled: true, Strict: true}, nil) + + _, diagnostics, err := tc.Check(` +local fs = require("fs") +local volume = fs.get("app:temp") +local iterator, state = volume:readdir("/") +if iterator then + for entry in iterator, state do + local name: string = entry.name + local kind: "file" | "directory" = entry.type + end +end +`, "fs_readdir_types.lua", map[string]*io.Manifest{"fs": ModuleTypes()}) + require.NoError(t, err) + require.False(t, code.HasErrors(diagnostics), "unexpected diagnostics: %v", diagnostics) } diff --git a/runtime/lua/modules/queue/module.go b/runtime/lua/modules/queue/module.go index d284bcde8..2a078109b 100644 --- a/runtime/lua/modules/queue/module.go +++ b/runtime/lua/modules/queue/module.go @@ -214,7 +214,7 @@ func messageHeader(l *lua.LState) int { return 2 } - l.Push(toLuaValue(val)) + l.Push(normalizeHeaderValue(val)) l.Push(lua.LNil) return 2 } @@ -228,7 +228,7 @@ func messageHeaders(l *lua.LState) int { headers := msg.delivery.Message.Headers tbl := lua.CreateTable(0, len(headers)) for key, val := range headers { - tbl.RawSetString(key, toLuaValue(val)) + tbl.RawSetString(key, normalizeHeaderValue(val)) } l.Push(tbl) l.Push(lua.LNil) @@ -353,7 +353,11 @@ func info(l *lua.LState) int { tbl := lua.CreateTable(0, len(bag)) for k, v := range bag { - tbl.RawSetString(k, toLuaValue(v)) + lv, convErr := luaconv.GoToLua(v) + if convErr != nil { + return internalError(l, convErr, "convert queue info") + } + tbl.RawSetString(k, lv) } l.Push(tbl) l.Push(lua.LNil) @@ -377,18 +381,21 @@ func toGoValue(v lua.LValue) any { } } -func toLuaValue(val any) lua.LValue { +// normalizeHeaderValue gives Lua consumers one stable header contract across +// drivers. The internal attrs.Bag remains typed because drivers need numbers, +// booleans, and binary values while publishing, but consumed metadata is +// exposed as a string just like other header-oriented runtime APIs. +// +// A nil value is treated as absent. Raw bytes become a Lua string without the +// "[1 2 3]" formatting that fmt.Sprint would otherwise produce. +func normalizeHeaderValue(val any) lua.LValue { switch v := val.(type) { case string: return lua.LString(v) - case int: - return lua.LNumber(v) - case int64: - return lua.LNumber(v) - case float64: - return lua.LNumber(v) - case bool: - return lua.LBool(v) + case []byte: + return lua.LString(string(v)) + case nil: + return lua.LNil default: return lua.LString(fmt.Sprintf("%v", v)) } diff --git a/runtime/lua/modules/queue/module_test.go b/runtime/lua/modules/queue/module_test.go index 14b5fa0d6..0fb7c8836 100644 --- a/runtime/lua/modules/queue/module_test.go +++ b/runtime/lua/modules/queue/module_test.go @@ -462,8 +462,8 @@ func TestMessageHeader(t *testing.T) { -- Test existing header local priority = msg:header("priority") - if priority ~= 5 then - error("expected priority 5, got: " .. tostring(priority)) + if priority ~= "5" then + error("expected normalized priority '5', got: " .. tostring(priority)) end local tag = msg:header("tag") @@ -502,8 +502,8 @@ func TestMessageHeaders(t *testing.T) { error("expected key1='value1', got: " .. tostring(headers.key1)) end - if headers.key2 ~= 42 then - error("expected key2=42, got: " .. tostring(headers.key2)) + if headers.key2 ~= "42" then + error("expected normalized key2='42', got: " .. tostring(headers.key2)) end `) if err != nil { @@ -683,6 +683,28 @@ func TestInfoReturnsAllStatsKeys(t *testing.T) { } } +func TestInfoReturnsStructuredErrorForUnsupportedStatValue(t *testing.T) { + info := attrs.NewBag() + info.Set("unsupported", make(chan int)) + + mgr := newMockManager() + mgr.driver = &mockInfoDriver{info: info} + mgr.addQueue("test:myqueue") + + l := setupStateWithManager(mgr) + defer l.Close() + + err := l.DoString(` + local info, err = queue.info("test:myqueue") + if info ~= nil then error("expected nil info") end + if not err then error("expected conversion error") end + if err:kind() ~= errors.INTERNAL then error("expected INTERNAL") end + `) + if err != nil { + t.Errorf("test failed: %v", err) + } +} + func TestInfoQueueNotFound(t *testing.T) { mgr := newMockManager() l := setupStateWithManager(mgr) @@ -809,8 +831,12 @@ func TestMessageHeaderTypes(t *testing.T) { msg := queueapi.NewMessageWithID("msg-types", payload.NewPayload("test", payload.String)) msg.Headers.Set("string_val", "hello") msg.Headers.Set("int_val", 42) + msg.Headers.Set("int32_val", int32(-7)) + msg.Headers.Set("uint_val", uint(8)) msg.Headers.Set("float_val", 3.14) msg.Headers.Set("bool_val", true) + msg.Headers.Set("binary_val", []byte("raw\x00bytes")) + msg.Headers.Set("nil_val", nil) l := setupStateWithDelivery(msg) defer l.Close() @@ -824,18 +850,43 @@ func TestMessageHeaderTypes(t *testing.T) { end local num = msg:header("int_val") - if num ~= 42 then - error("expected int 42, got: " .. tostring(num)) + if num ~= "42" then + error("expected normalized int '42', got: " .. tostring(num)) + end + + local int32 = msg:header("int32_val") + if int32 ~= "-7" then + error("expected normalized int32 '-7', got: " .. tostring(int32)) + end + + local uint = msg:header("uint_val") + if uint ~= "8" then + error("expected normalized uint '8', got: " .. tostring(uint)) end local flt = msg:header("float_val") - if flt ~= 3.14 then - error("expected float 3.14, got: " .. tostring(flt)) + if flt ~= "3.14" then + error("expected normalized float '3.14', got: " .. tostring(flt)) end local bool = msg:header("bool_val") - if bool ~= true then - error("expected bool true, got: " .. tostring(bool)) + if bool ~= "true" then + error("expected normalized bool 'true', got: " .. tostring(bool)) + end + + local binary = msg:header("binary_val") + if binary ~= "raw\0bytes" then + error("expected binary bytes to become a Lua string") + end + + local nil_value = msg:header("nil_val") + if nil_value ~= nil then + error("expected a nil header value to be treated as absent") + end + + local headers = msg:headers() + if headers.nil_val ~= nil then + error("expected nil-valued header to be absent from headers table") end `) if err != nil { diff --git a/runtime/lua/modules/queue/spec.md b/runtime/lua/modules/queue/spec.md index 2aad8c9b2..a56a6b3de 100644 --- a/runtime/lua/modules/queue/spec.md +++ b/runtime/lua/modules/queue/spec.md @@ -25,11 +25,15 @@ Publishes a message to a queue. **headers fields:** Headers can be any key-value pairs where keys are strings and values are string, integer, number, or boolean. +Those input types remain available internally so drivers can map broker-specific +properties correctly. On the consumer side, `Message:header()` and +`Message:headers()` normalize every value to a string; missing or nil values +are exposed as nil. Two header namespaces are recognized: - **Neutral keys** (driver-agnostic): `priority`, `correlation_id`, `reply_to`, `encoding`, `schema`, `source`, `traceparent`, `tracestate`, `content_type`, `message_type`. Drivers that have a native equivalent translate these onto typed fields (e.g. AMQP `CorrelationId`, SQS system attributes); otherwise they pass through unchanged. -- **Driver-prefixed keys**: `amqp.*`, `sqs.*` (extensible to `kafka.*`, `jetstream.*`). The matching driver may consume some of them (e.g. AMQP consumes `amqp.mandatory` and `amqp.expiration`); any key the matching driver does not special-case, and every prefixed key on a non-matching driver, is carried through to the consumer verbatim. Publishers can therefore rely on every header they set being visible on the receive side. +- **Driver-prefixed keys**: `amqp.*`, `sqs.*` (extensible to `kafka.*`, `jetstream.*`). The matching driver may consume some of them (e.g. AMQP consumes `amqp.mandatory` and `amqp.expiration`); any key the matching driver does not special-case, and every prefixed key on a non-matching driver, keeps its key through to the consumer. Publishers can therefore rely on every header they set being visible on the receive side, with its value normalized to string by the Lua consumer API. Keys prefixed with `x_` (e.g. `x_original_queue`, `x_dead_letter_reason`, `x_dead_letter_time`, `attempts`) are reserved for DLQ/redelivery bookkeeping written by the driver and MUST NOT be set by publishers. @@ -125,7 +129,7 @@ Returned by `queue.message()`. Represents a queue message being processed. | Method | Signature | Returns | Notes | |--------|-----------|---------|-------| | id | () | string, error | Message unique identifier | -| header | (key: string) | any, error | Get single header value, nil if not found | +| header | (key: string) | string?, error | Get normalized string value, nil if not found | | headers | () | table, error | Get all headers as table | | ack | () | boolean, error | Acknowledge successful processing. Single-shot. | | nack | () | boolean, error | Signal failure, request redelivery or dead-letter. Single-shot. | @@ -143,7 +147,7 @@ local msg = queue.message() local id = msg:id() ``` -#### msg:header(key: string) → any, error +#### msg:header(key: string) → string?, error Returns a single header value by key. @@ -152,7 +156,7 @@ Returns a single header value by key. | key | string | yes | Header key to retrieve | **Returns:** -- `value, nil` - header value (string, number, or boolean) if exists +- `value, nil` - header value normalized to string if it exists - `nil, nil` - if header doesn't exist **Example:** @@ -165,9 +169,11 @@ local correlation_id = msg:header("correlation_id") #### msg:headers() → table, error -Returns all message headers as a table. +Returns all message headers as a string-valued table. Drivers retain typed +values internally for broker-specific publishing, but the Lua consumer API +normalizes strings, numbers, booleans, and binary attributes consistently. -**Returns:** `table, nil` - table with all headers (empty table if no headers) +**Returns:** `{[string]: string}, nil` - table with all headers (empty table if no headers) **Example:** diff --git a/runtime/lua/modules/queue/types.go b/runtime/lua/modules/queue/types.go index 7a8ddc39d..a85148b7b 100644 --- a/runtime/lua/modules/queue/types.go +++ b/runtime/lua/modules/queue/types.go @@ -15,11 +15,11 @@ var messageType = typ.NewInterface("queue.Message", []typ.Method{ }, { Name: "header", - Type: typ.Func().Param("_", typ.Self).Param("key", typ.String).Returns(typ.String, typ.NewOptional(typ.LuaError)).Build(), + Type: typ.Func().Param("_", typ.Self).Param("key", typ.String).Returns(typ.NewOptional(typ.String), typ.NewOptional(typ.LuaError)).Build(), }, { Name: "headers", - Type: typ.Func().Param("_", typ.Self).Returns(typ.NewMap(typ.String, typ.Any), typ.NewOptional(typ.LuaError)).Build(), + Type: typ.Func().Param("_", typ.Self).Returns(typ.NewMap(typ.String, typ.String), typ.NewOptional(typ.LuaError)).Build(), }, { Name: "ack", diff --git a/runtime/lua/modules/queue/types_test.go b/runtime/lua/modules/queue/types_test.go index f6b1998fe..002709109 100644 --- a/runtime/lua/modules/queue/types_test.go +++ b/runtime/lua/modules/queue/types_test.go @@ -5,28 +5,57 @@ package queue import ( "testing" + "github.com/stretchr/testify/require" + "github.com/wippyai/go-lua/types/io" "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/runtime/runtime/lua/code" ) -func TestMessageHeaderTypeReturnsString(t *testing.T) { +func TestMessageHeaderTypesMatchNormalizedRuntimeValues(t *testing.T) { manifest := ModuleTypes() message, ok := manifest.LookupType("Message") if !ok { t.Fatal("Message type is not defined") } + methods := make(map[string]*typ.Function) for _, method := range message.(*typ.Interface).Methods { - if method.Name != "header" { - continue - } - if len(method.Type.Returns) != 2 { - t.Fatalf("header returns %d values, want value and error", len(method.Type.Returns)) - } - if !typ.TypeEquals(method.Type.Returns[0], typ.String) { - t.Fatalf("header value = %s, want string", method.Type.Returns[0]) - } - return + methods[method.Name] = method.Type } - t.Fatal("header method is not defined") + header := methods["header"] + if header == nil || len(header.Returns) != 2 { + t.Fatal("header must return value and error") + } + if !typ.TypeEquals(header.Returns[0], typ.NewOptional(typ.String)) { + t.Fatalf("header value = %s, want string?", header.Returns[0]) + } + + headers := methods["headers"] + if headers == nil || len(headers.Returns) != 2 { + t.Fatal("headers must return table and error") + } + if !typ.TypeEquals(headers.Returns[0], typ.NewMap(typ.String, typ.String)) { + t.Fatalf("headers value = %s, want {[string]: string}", headers.Returns[0]) + } +} + +func TestMessageHeaderTypesSupportNilNarrowing(t *testing.T) { + tc := code.NewTypeChecker(code.TypeCheckConfig{Enabled: true, Strict: true}, nil) + + _, diagnostics, err := tc.Check(` +local queue = require("queue") +local msg = queue.message() +local value = msg:header("correlation_id") +if value then +local normalized: string = value +end +local headers = msg:headers() +local all_value = headers["correlation_id"] +if all_value then + local normalized: string = all_value +end +`, "queue_header_types.lua", map[string]*io.Manifest{"queue": ModuleTypes()}) + require.NoError(t, err) + require.False(t, code.HasErrors(diagnostics), "unexpected diagnostics: %v", diagnostics) } diff --git a/tests/app/src/queue/task_handler.lua b/tests/app/src/queue/task_handler.lua index d76ad4b5c..70d5e0c51 100644 --- a/tests/app/src/queue/task_handler.lua +++ b/tests/app/src/queue/task_handler.lua @@ -14,7 +14,7 @@ local function main(body) end local msg_id = msg:id() - local correlation_id = msg:header("correlation_id") + local correlation_id = msg:header("correlation_id") or "" logger:info("processing task", { msg_id = msg_id, diff --git a/tests/app/src/test/queue/header_overrides.lua b/tests/app/src/test/queue/header_overrides.lua index af7bd0f1e..686df1b7e 100644 --- a/tests/app/src/test/queue/header_overrides.lua +++ b/tests/app/src/test/queue/header_overrides.lua @@ -36,7 +36,7 @@ local function main() assert.not_nil(rec, "consumer should have processed the message") assert.eq(rec.correlation_id, corr, "correlation_id preserved") - assert.eq(rec.headers.priority, 9, "priority preserved") + assert.eq(rec.headers.priority, "9", "priority normalized to string") assert.eq(rec.headers.partition_key, "customer-7", "partition_key preserved") assert.eq(rec.headers.reply_to, "app.queue:replies", "reply_to preserved") assert.eq(rec.headers["amqp.expiration"], "30000", "amqp-prefixed header passed through") diff --git a/tests/app/src/test/queue/integration.lua b/tests/app/src/test/queue/integration.lua index a47401bf4..c00f889e0 100644 --- a/tests/app/src/test/queue/integration.lua +++ b/tests/app/src/test/queue/integration.lua @@ -48,7 +48,7 @@ local function main() assert.not_nil(rec, "handler should have stored record keyed by correlation_id") assert.eq(rec.correlation_id, corr_id, "record should carry the correlation_id we sent") assert.not_nil(rec.headers, "record should carry the headers table") - assert.eq(rec.headers.priority, 5, "priority header should round-trip") + assert.eq(rec.headers.priority, "5", "priority header should normalize to string") assert.eq(rec.headers.partition_key, "tenant-42", "partition_key should round-trip") return true diff --git a/tests/app/src/test/sqs/handler.lua b/tests/app/src/test/sqs/handler.lua index 765141dda..2fd74f36d 100644 --- a/tests/app/src/test/sqs/handler.lua +++ b/tests/app/src/test/sqs/handler.lua @@ -12,7 +12,7 @@ local function main(body) end local msg_id = msg:id() - local correlation_id = msg:header("correlation_id") + local correlation_id = msg:header("correlation_id") or "" local s, store_err = store.get("app.test.store:memory") if store_err then From 3d6cd0a9475c0a8229f8de144e673b1108f6feb3 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 10 Jul 2026 10:34:03 -0400 Subject: [PATCH 5/5] fix(tests): make app suite deterministic --- runtime/lua/modules/process/types.go | 9 +++++- runtime/lua/modules/process/types_test.go | 28 +++++++++++++++++ tests/app/src/_index.yaml | 2 +- .../app/src/test/funcs/undeclared_module.lua | 9 ++++-- tests/app/src/test/http/cors_actual.lua | 2 +- tests/app/src/test/http/cors_localhost.lua | 2 +- tests/app/src/test/http/cors_preflight.lua | 2 +- tests/app/src/test/http/cors_reject.lua | 2 +- tests/app/src/test/http/cors_wildcard.lua | 2 +- tests/app/src/test/http/request_stream.lua | 6 ++-- tests/app/src/test/http/response_headers.lua | 12 ++++---- tests/app/src/test/http/stream_echo.lua | 20 +++++++------ tests/app/src/test/http_auth/test_bearer.lua | 4 +-- tests/app/src/test/http_auth/test_query.lua | 4 +-- .../src/test/http_auth/test_unauthorized.lua | 2 +- tests/app/src/test/httpclient/get.lua | 2 +- tests/app/src/test/httpclient/headers.lua | 4 +-- tests/app/src/test/httpclient/post.lua | 4 +-- tests/app/src/test/httpclient/query.lua | 2 +- tests/app/src/test/httpclient/sse.lua | 2 +- tests/app/src/test/httpclient/stream.lua | 2 +- tests/app/src/test/network/batch_probe.lua | 4 +-- tests/app/src/test/network/broken_proxy.lua | 2 +- tests/app/src/test/network/denied_probe.lua | 2 +- tests/app/src/test/network/empty_clearnet.lua | 2 +- tests/app/src/test/network/overlay_callee.lua | 2 +- tests/app/src/test/network/overlay_worker.lua | 2 +- .../app/src/test/network/unknown_network.lua | 2 +- .../test/registry/dependency_requirements.lua | 2 +- tests/app/src/test/sql/basic.lua | 6 ++++ .../app/src/test/wasm/call_wasi_http_wasm.lua | 2 +- .../src/test/websocket/echo_server/test.lua | 2 +- tests/app/src/uploads/test.lua | 2 +- tests/app/test.sh | 30 ++++++++++++++----- 34 files changed, 121 insertions(+), 61 deletions(-) create mode 100644 runtime/lua/modules/process/types_test.go diff --git a/runtime/lua/modules/process/types.go b/runtime/lua/modules/process/types.go index d6cdc1570..8840f5885 100644 --- a/runtime/lua/modules/process/types.go +++ b/runtime/lua/modules/process/types.go @@ -67,6 +67,13 @@ var processOptionsType = typ.NewRecord(). Field("upgradable", typ.Boolean). Build() +// set_options applies a partial update. get_options always returns both fields, +// but callers may set either option independently (or pass an empty table). +var processOptionsUpdateType = typ.NewRecord(). + OptField("trap_links", typ.Boolean). + OptField("upgradable", typ.Boolean). + Build() + var eventType = typ.NewRecord(). Field("CANCEL", typ.String). Field("EXIT", typ.String). @@ -236,7 +243,7 @@ func ModuleTypes() *io.Manifest { Returns(processOptionsType). Build()}, {Name: "set_options", Type: typ.Func(). - Param("opts", processOptionsType). + Param("opts", processOptionsUpdateType). Returns(typ.Boolean, typ.NewOptional(typ.LuaError)). Build()}, {Name: "monitor", Type: typ.Func(). diff --git a/runtime/lua/modules/process/types_test.go b/runtime/lua/modules/process/types_test.go new file mode 100644 index 000000000..5f4cc3ba1 --- /dev/null +++ b/runtime/lua/modules/process/types_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MPL-2.0 + +package process + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/wippyai/go-lua/types/io" + "github.com/wippyai/runtime/runtime/lua/code" +) + +func TestSetOptionsTypeAcceptsPartialUpdates(t *testing.T) { + tc := code.NewTypeChecker(code.TypeCheckConfig{Enabled: true, Strict: true}, nil) + + _, diagnostics, err := tc.Check(` +local process = require("process") +process.set_options({ trap_links = true }) +process.set_options({ upgradable = true }) +process.set_options({}) + +local options = process.get_options() +local trap_links: boolean = options.trap_links +local upgradable: boolean = options.upgradable +`, "process_options_types.lua", map[string]*io.Manifest{"process": ModuleTypes()}) + require.NoError(t, err) + require.False(t, code.HasErrors(diagnostics), "unexpected diagnostics: %v", diagnostics) +} diff --git a/tests/app/src/_index.yaml b/tests/app/src/_index.yaml index eef99e89f..af38d4ba1 100644 --- a/tests/app/src/_index.yaml +++ b/tests/app/src/_index.yaml @@ -9,7 +9,7 @@ entries: kind: http.service meta: comment: Main HTTP gateway service - addr: :8085 + addr: :18085 lifecycle: auto_start: true diff --git a/tests/app/src/test/funcs/undeclared_module.lua b/tests/app/src/test/funcs/undeclared_module.lua index 21ff6a94c..f1f844d21 100644 --- a/tests/app/src/test/funcs/undeclared_module.lua +++ b/tests/app/src/test/funcs/undeclared_module.lua @@ -1,11 +1,14 @@ -- SPDX-License-Identifier: MPL-2.0 -- This function tries to require a module that is NOT declared in its modules list. --- This should fail at runtime because 'json' is not in the modules list. +-- Keep the name dynamic so the application-wide static declaration check does +-- not reject this intentional negative fixture before the runtime test can run. +-- The scoped runtime require still receives "json" and must deny it. local function main(args) --- Attempt to require a module not in the modules list - local json = require("json") +-- Attempt to require a module not in the modules list. + local module_name = "json" + local json = require(module_name) -- If we get here, the restriction didn't work return { encoded = json.encode({ test = "value" }) } diff --git a/tests/app/src/test/http/cors_actual.lua b/tests/app/src/test/http/cors_actual.lua index 891a273a1..ad3346845 100644 --- a/tests/app/src/test/http/cors_actual.lua +++ b/tests/app/src/test/http/cors_actual.lua @@ -7,7 +7,7 @@ local function main() local http = require("http_client") -- Test actual POST request with Origin header - local resp, err = http.post("http://localhost:8085/stream-echo", { + local resp, err = http.post("http://localhost:18085/stream-echo", { body = "test", headers = { ["Origin"] = "http://localhost:5173" diff --git a/tests/app/src/test/http/cors_localhost.lua b/tests/app/src/test/http/cors_localhost.lua index 641972d95..a4d305e10 100644 --- a/tests/app/src/test/http/cors_localhost.lua +++ b/tests/app/src/test/http/cors_localhost.lua @@ -16,7 +16,7 @@ local function main() } for _, origin in ipairs(test_origins) do - local resp, err = http.post("http://localhost:8085/stream-echo", { + local resp, err = http.post("http://localhost:18085/stream-echo", { body = "test", headers = { ["Origin"] = origin diff --git a/tests/app/src/test/http/cors_preflight.lua b/tests/app/src/test/http/cors_preflight.lua index 4595ac7a7..7ef9332e6 100644 --- a/tests/app/src/test/http/cors_preflight.lua +++ b/tests/app/src/test/http/cors_preflight.lua @@ -7,7 +7,7 @@ local function main() local http = require("http_client") -- Test preflight OPTIONS request with CORS headers - local resp, err = http.request("OPTIONS", "http://localhost:8085/stream-echo", { + local resp, err = http.request("OPTIONS", "http://localhost:18085/stream-echo", { headers = { ["Origin"] = "http://localhost:5173", ["Access-Control-Request-Method"] = "POST", diff --git a/tests/app/src/test/http/cors_reject.lua b/tests/app/src/test/http/cors_reject.lua index 419c2ac9d..f97932999 100644 --- a/tests/app/src/test/http/cors_reject.lua +++ b/tests/app/src/test/http/cors_reject.lua @@ -7,7 +7,7 @@ local function main() local http = require("http_client") -- Test with disallowed origin (not in cors.allow.origins config) - local resp, err = http.post("http://localhost:8085/stream-echo", { + local resp, err = http.post("http://localhost:18085/stream-echo", { body = "test", headers = { ["Origin"] = "https://evil-site.com" diff --git a/tests/app/src/test/http/cors_wildcard.lua b/tests/app/src/test/http/cors_wildcard.lua index 5c111f03c..62cf7cc85 100644 --- a/tests/app/src/test/http/cors_wildcard.lua +++ b/tests/app/src/test/http/cors_wildcard.lua @@ -15,7 +15,7 @@ local function main() } for _, origin in ipairs(test_origins) do - local resp, err = http.post("http://localhost:8085/stream-echo", { + local resp, err = http.post("http://localhost:18085/stream-echo", { body = "test", headers = { ["Origin"] = origin diff --git a/tests/app/src/test/http/request_stream.lua b/tests/app/src/test/http/request_stream.lua index 6518c6023..7ce687d67 100644 --- a/tests/app/src/test/http/request_stream.lua +++ b/tests/app/src/test/http/request_stream.lua @@ -9,7 +9,7 @@ local function main() -- Test 1: Request with body - should stream successfully local body = "Hello from streaming test!" - local resp, err = http.post("http://localhost:8085/test/request-stream", { + local resp, err = http.post("http://localhost:18085/test/request-stream", { body = body }) assert.is_nil(err, "POST should not error") @@ -26,7 +26,7 @@ local function main() -- Test 2: Large body - multiple chunks local large_body = string.rep("X", 2048) - local resp2, err2 = http.post("http://localhost:8085/test/request-stream", { + local resp2, err2 = http.post("http://localhost:18085/test/request-stream", { body = large_body }) assert.is_nil(err2, "large POST should not error") @@ -38,7 +38,7 @@ local function main() assert.eq(data2.content, large_body, "large content matches") -- Test 3: Empty body - local resp3, err3 = http.post("http://localhost:8085/test/request-stream", { + local resp3, err3 = http.post("http://localhost:18085/test/request-stream", { body = "" }) assert.is_nil(err3, "empty POST should not error") diff --git a/tests/app/src/test/http/response_headers.lua b/tests/app/src/test/http/response_headers.lua index 7460d2246..ed4fd253b 100644 --- a/tests/app/src/test/http/response_headers.lua +++ b/tests/app/src/test/http/response_headers.lua @@ -7,35 +7,35 @@ local function main() local http = require("http_client") -- Test 1: Normal flow - headers before write (should succeed) - local resp1, err1 = http.get("http://localhost:8085/test/response-headers?test=normal") + local resp1, err1 = http.get("http://localhost:18085/test/response-headers?test=normal") assert.is_nil(err1, "normal test should not error") assert.eq(resp1.status_code, 200, "status 200") -- Check custom header was set assert.eq(resp1.headers["X-Custom"], "test-value", "custom header set") -- Test 2: Write then set_status (should error internally but not crash) - local resp2, err2 = http.get("http://localhost:8085/test/response-headers?test=write_then_status") + local resp2, err2 = http.get("http://localhost:18085/test/response-headers?test=write_then_status") assert.is_nil(err2, "write_then_status should not crash") -- The response should still be sent (the write happened) assert.eq(resp2.body, "some data", "body was written") -- Test 3: Write then set_header (should error internally) - local resp3, err3 = http.get("http://localhost:8085/test/response-headers?test=write_then_header") + local resp3, err3 = http.get("http://localhost:18085/test/response-headers?test=write_then_header") assert.is_nil(err3, "write_then_header should not crash") assert.eq(resp3.body, "some data", "body was written") -- Test 4: Write then set_content_type (should error internally) - local resp4, err4 = http.get("http://localhost:8085/test/response-headers?test=write_then_content_type") + local resp4, err4 = http.get("http://localhost:18085/test/response-headers?test=write_then_content_type") assert.is_nil(err4, "write_then_content_type should not crash") assert.eq(resp4.body, "some data", "body was written") -- Test 5: Write then set_transfer (should error internally) - local resp5, err5 = http.get("http://localhost:8085/test/response-headers?test=write_then_transfer") + local resp5, err5 = http.get("http://localhost:18085/test/response-headers?test=write_then_transfer") assert.is_nil(err5, "write_then_transfer should not crash") assert.eq(resp5.body, "some data", "body was written") -- Test 6: SSE auto-headers - local resp6, err6 = http.get("http://localhost:8085/test/response-headers?test=sse_auto") + local resp6, err6 = http.get("http://localhost:18085/test/response-headers?test=sse_auto") assert.is_nil(err6, "sse_auto should not error") -- SSE sets content-type automatically local ct = resp6.headers["Content-Type"] diff --git a/tests/app/src/test/http/stream_echo.lua b/tests/app/src/test/http/stream_echo.lua index fda6bc364..fd66e8a2e 100644 --- a/tests/app/src/test/http/stream_echo.lua +++ b/tests/app/src/test/http/stream_echo.lua @@ -8,18 +8,19 @@ local function main() -- Test 1: Basic stream echo with small body local body = "Hello, streaming world!" - local resp, err = http.post("http://localhost:8085/stream-echo", { + local resp, err = http.post("http://localhost:18085/stream-echo", { body = body, stream = true }) assert.is_nil(err, "POST should not error") assert.eq(resp.status_code, 200, "status code 200") - assert.not_nil(resp.stream, "stream object returned") + local response_stream = resp.stream + assert.not_nil(response_stream, "stream object returned") -- Read echoed data local chunks = {} while true do - local chunk, read_err = resp.stream:read(1024) + local chunk, read_err = response_stream:read(1024) if read_err then break end @@ -28,25 +29,26 @@ local function main() end table.insert(chunks, chunk) end - resp.stream:close() + response_stream:close() local echoed = table.concat(chunks) assert.eq(echoed, body, "echoed body matches") -- Test 2: Larger body local large_body = string.rep("X", 4096) - local resp2, err2 = http.post("http://localhost:8085/stream-echo", { + local resp2, err2 = http.post("http://localhost:18085/stream-echo", { body = large_body, stream = true }) assert.is_nil(err2, "large POST should not error") assert.eq(resp2.status_code, 200, "status code 200 for large body") - assert.not_nil(resp2.stream, "stream object for large body") + local response_stream2 = resp2.stream + assert.not_nil(response_stream2, "stream object for large body") local chunks2 = {} local total = 0 while true do - local chunk, read_err = resp2.stream:read(1024) + local chunk, read_err = response_stream2:read(1024) if read_err then break end @@ -56,14 +58,14 @@ local function main() table.insert(chunks2, chunk) total = total + #chunk end - resp2.stream:close() + response_stream2:close() assert.eq(total, 4096, "large body echoed completely") local echoed2 = table.concat(chunks2) assert.eq(echoed2, large_body, "large echoed body matches") -- Test 3: Non-streaming response (without stream=true) - local resp3, err3 = http.post("http://localhost:8085/stream-echo", { + local resp3, err3 = http.post("http://localhost:18085/stream-echo", { body = "test data" }) assert.is_nil(err3, "non-streaming POST should not error") diff --git a/tests/app/src/test/http_auth/test_bearer.lua b/tests/app/src/test/http_auth/test_bearer.lua index 5a7d908f7..89d4c6cda 100644 --- a/tests/app/src/test/http_auth/test_bearer.lua +++ b/tests/app/src/test/http_auth/test_bearer.lua @@ -6,7 +6,7 @@ local function main() local http = require("http_client") local json = require("json") - local login_res, err = http.post("http://localhost:8085/auth/login", { + local login_res, err = http.post("http://localhost:18085/auth/login", { body = json.encode({ username = "testuser" }), headers = { ["Content-Type"] = "application/json" } }) @@ -25,7 +25,7 @@ local function main() local token = login_body.token - local protected_res, err = http.get("http://localhost:8085/auth/protected", { + local protected_res, err = http.get("http://localhost:18085/auth/protected", { headers = { ["Authorization"] = "Bearer " .. token } }) if err then diff --git a/tests/app/src/test/http_auth/test_query.lua b/tests/app/src/test/http_auth/test_query.lua index 0a1148117..34e743fd5 100644 --- a/tests/app/src/test/http_auth/test_query.lua +++ b/tests/app/src/test/http_auth/test_query.lua @@ -6,7 +6,7 @@ local function main() local http = require("http_client") local json = require("json") - local login_res, err = http.post("http://localhost:8085/auth/login", { + local login_res, err = http.post("http://localhost:18085/auth/login", { body = json.encode({ username = "queryuser" }), headers = { ["Content-Type"] = "application/json" } }) @@ -25,7 +25,7 @@ local function main() local token = login_body.token - local url = "http://localhost:8085/auth/protected?x-auth-token=" .. token + local url = "http://localhost:18085/auth/protected?x-auth-token=" .. token local protected_res, err = http.get(url) if err then return false, "protected request error: " .. tostring(err) diff --git a/tests/app/src/test/http_auth/test_unauthorized.lua b/tests/app/src/test/http_auth/test_unauthorized.lua index 78583e9cb..cd9550015 100644 --- a/tests/app/src/test/http_auth/test_unauthorized.lua +++ b/tests/app/src/test/http_auth/test_unauthorized.lua @@ -6,7 +6,7 @@ local function main() local http = require("http_client") local json = require("json") - local res, err = http.get("http://localhost:8085/auth/protected") + local res, err = http.get("http://localhost:18085/auth/protected") if err then return false, "request error: " .. tostring(err) end diff --git a/tests/app/src/test/httpclient/get.lua b/tests/app/src/test/httpclient/get.lua index 6a102a16d..3f0218c4b 100644 --- a/tests/app/src/test/httpclient/get.lua +++ b/tests/app/src/test/httpclient/get.lua @@ -8,7 +8,7 @@ local function main() local json = require("json") -- Basic GET request to local hello endpoint - local resp, err = http.get("http://localhost:8085/hello") + local resp, err = http.get("http://localhost:18085/hello") assert.is_nil(err, "GET should not error") assert.not_nil(resp, "response returned") assert.eq(resp.status_code, 200, "status code 200") diff --git a/tests/app/src/test/httpclient/headers.lua b/tests/app/src/test/httpclient/headers.lua index 8de1ee1c2..0b4e5fa2d 100644 --- a/tests/app/src/test/httpclient/headers.lua +++ b/tests/app/src/test/httpclient/headers.lua @@ -8,7 +8,7 @@ local function main() local json = require("json") -- Test sending custom headers - local resp, err = http.get("http://localhost:8085/test/echo?test=headers&header=X-Custom-Header", { + local resp, err = http.get("http://localhost:18085/test/echo?test=headers&header=X-Custom-Header", { headers = {["X-Custom-Header"] = "custom-value"} }) assert.is_nil(err, "GET with headers should not error") @@ -19,7 +19,7 @@ local function main() assert.eq(data.header_value, "custom-value", "custom header received") -- Test response headers (custom_headers endpoint sets X-Custom-Header) - local resp2, err2 = http.get("http://localhost:8085/test/echo?test=custom_headers") + local resp2, err2 = http.get("http://localhost:18085/test/echo?test=custom_headers") assert.is_nil(err2, "GET custom_headers should not error") assert.eq(resp2.status_code, 200, "status code 200") assert.eq(resp2.headers["X-Custom-Header"], "custom-value", "response has custom header") diff --git a/tests/app/src/test/httpclient/post.lua b/tests/app/src/test/httpclient/post.lua index 088e855e4..54893df83 100644 --- a/tests/app/src/test/httpclient/post.lua +++ b/tests/app/src/test/httpclient/post.lua @@ -8,7 +8,7 @@ local json = require("json") local function main() -- POST with JSON body local post_data = json.encode({name = "test", value = 42}) - local resp, err = http.post("http://localhost:8085/test/echo?test=body", { + local resp, err = http.post("http://localhost:18085/test/echo?test=body", { headers = {["Content-Type"] = "application/json"}, body = post_data }) @@ -24,7 +24,7 @@ local function main() -- Test POST to stream echo (echo back body) local test_body = "POST body test data" - local resp2, err2 = http.post("http://localhost:8085/stream-echo", { + local resp2, err2 = http.post("http://localhost:18085/stream-echo", { body = test_body }) assert.is_nil(err2, "POST to stream-echo should not error") diff --git a/tests/app/src/test/httpclient/query.lua b/tests/app/src/test/httpclient/query.lua index e06fff9f0..215df47ba 100644 --- a/tests/app/src/test/httpclient/query.lua +++ b/tests/app/src/test/httpclient/query.lua @@ -8,7 +8,7 @@ local function main() local json = require("json") -- Query parameters via options - local resp, err = http.get("http://localhost:8085/test/echo?test=query", { + local resp, err = http.get("http://localhost:18085/test/echo?test=query", { query = {foo = "bar", num = "123", value = "test_value"} }) assert.is_nil(err, "GET with query should not error") diff --git a/tests/app/src/test/httpclient/sse.lua b/tests/app/src/test/httpclient/sse.lua index c7dcabf10..d6ddb8bdb 100644 --- a/tests/app/src/test/httpclient/sse.lua +++ b/tests/app/src/test/httpclient/sse.lua @@ -7,7 +7,7 @@ local function main() local http = require("http_client") -- SSE test - get events via streaming response - local resp, err = http.get("http://localhost:8085/test/echo?test=sse", { + local resp, err = http.get("http://localhost:18085/test/echo?test=sse", { stream = true }) assert.is_nil(err, "SSE GET should not error") diff --git a/tests/app/src/test/httpclient/stream.lua b/tests/app/src/test/httpclient/stream.lua index b6f581e6a..88b2df9e1 100644 --- a/tests/app/src/test/httpclient/stream.lua +++ b/tests/app/src/test/httpclient/stream.lua @@ -8,7 +8,7 @@ local function main() -- Streaming POST request (to stream-echo which returns chunked response) local test_body = string.rep("X", 1024) - local resp, err = http.post("http://localhost:8085/stream-echo", { + local resp, err = http.post("http://localhost:18085/stream-echo", { body = test_body, stream = true }) diff --git a/tests/app/src/test/network/batch_probe.lua b/tests/app/src/test/network/batch_probe.lua index 1b416c017..254b9f709 100644 --- a/tests/app/src/test/network/batch_probe.lua +++ b/tests/app/src/test/network/batch_probe.lua @@ -18,10 +18,10 @@ local function main() local http = require("http_client") local responses, err = http.request_batch({ - { "GET", "http://localhost:8085/hello" }, + { "GET", "http://localhost:18085/hello" }, { "GET", - "http://localhost:8085/hello", + "http://localhost:18085/hello", { overlay_network = "app.test.network:fast_socks5", timeout = "500ms", diff --git a/tests/app/src/test/network/broken_proxy.lua b/tests/app/src/test/network/broken_proxy.lua index a020bbd3b..04734a57c 100644 --- a/tests/app/src/test/network/broken_proxy.lua +++ b/tests/app/src/test/network/broken_proxy.lua @@ -10,7 +10,7 @@ local assert = require("assert2") local function main() local http = require("http_client") - local resp, err = http.get("http://localhost:8085/hello", { + local resp, err = http.get("http://localhost:18085/hello", { overlay_network = "app.test.network:broken_socks5", timeout = "2s", }) diff --git a/tests/app/src/test/network/denied_probe.lua b/tests/app/src/test/network/denied_probe.lua index 1fc6c16ad..5f1d324db 100644 --- a/tests/app/src/test/network/denied_probe.lua +++ b/tests/app/src/test/network/denied_probe.lua @@ -21,7 +21,7 @@ local function main(args) -- Edge 1: httpclient — denial returns (nil, err). local http = require("http_client") - local resp, http_err = http.get("http://localhost:8085/hello", { + local resp, http_err = http.get("http://localhost:18085/hello", { overlay_network = target, timeout = "500ms", }) diff --git a/tests/app/src/test/network/empty_clearnet.lua b/tests/app/src/test/network/empty_clearnet.lua index 40a479986..5fa85e6c3 100644 --- a/tests/app/src/test/network/empty_clearnet.lua +++ b/tests/app/src/test/network/empty_clearnet.lua @@ -10,7 +10,7 @@ local function main() local http = require("http_client") local json = require("json") - local resp, err = http.get("http://localhost:8085/hello", { + local resp, err = http.get("http://localhost:18085/hello", { timeout = "2s", }) assert.is_nil(err, "clearnet GET failed: " .. tostring(err)) diff --git a/tests/app/src/test/network/overlay_callee.lua b/tests/app/src/test/network/overlay_callee.lua index 4c07fceb3..849f3692a 100644 --- a/tests/app/src/test/network/overlay_callee.lua +++ b/tests/app/src/test/network/overlay_callee.lua @@ -8,7 +8,7 @@ local function main(args) local http = require("http_client") - local url = (args and args.url) or "http://localhost:8085/hello" + local url = (args and args.url) or "http://localhost:18085/hello" local resp, err = http.get(url, { timeout = "2s" }) if err ~= nil then diff --git a/tests/app/src/test/network/overlay_worker.lua b/tests/app/src/test/network/overlay_worker.lua index 4c3fa3017..9a7122465 100644 --- a/tests/app/src/test/network/overlay_worker.lua +++ b/tests/app/src/test/network/overlay_worker.lua @@ -7,7 +7,7 @@ local function main(args) local http = require("http_client") - local url = (args and args.url) or "http://localhost:8085/hello" + local url = (args and args.url) or "http://localhost:18085/hello" local resp, err = http.get(url, { timeout = "2s" }) if err ~= nil then diff --git a/tests/app/src/test/network/unknown_network.lua b/tests/app/src/test/network/unknown_network.lua index 99b469307..78bedd975 100644 --- a/tests/app/src/test/network/unknown_network.lua +++ b/tests/app/src/test/network/unknown_network.lua @@ -9,7 +9,7 @@ local assert = require("assert2") local function main() local http = require("http_client") - local resp, err = http.get("http://localhost:8085/hello", { + local resp, err = http.get("http://localhost:18085/hello", { overlay_network = "app.test.network:does_not_exist", timeout = "1s", }) diff --git a/tests/app/src/test/registry/dependency_requirements.lua b/tests/app/src/test/registry/dependency_requirements.lua index 06d82c52c..0737c1bf9 100644 --- a/tests/app/src/test/registry/dependency_requirements.lua +++ b/tests/app/src/test/registry/dependency_requirements.lua @@ -183,7 +183,7 @@ local function main() assert.ok(matched > 0, "matched requirement by name") - local resp, http_err = http.get("http://localhost:8085/dummy/ping") + local resp, http_err = http.get("http://localhost:18085/dummy/ping") assert.is_nil(http_err, "http get /dummy/ping no error") assert.not_nil(resp, "http response returned") assert.eq(resp.status_code, 200, "http /dummy/ping status 200") diff --git a/tests/app/src/test/sql/basic.lua b/tests/app/src/test/sql/basic.lua index a0aa53143..dd59658f0 100644 --- a/tests/app/src/test/sql/basic.lua +++ b/tests/app/src/test/sql/basic.lua @@ -10,6 +10,9 @@ local function main() local db, err = sql.get("app.test.sql:testdb") assert.is_nil(err, "should get database without error") assert.not_nil(db, "should have database connection") + if not db then + error("database connection is required") + end -- Check database type local dbtype, err2 = db:type() @@ -20,6 +23,9 @@ local function main() local stats, err3 = db:stats() assert.is_nil(err3, "stats should not error") assert.not_nil(stats, "should have stats") + if not stats then + error("database stats are required") + end assert.not_nil(stats.open_connections, "should have open_connections") assert.not_nil(stats.in_use, "should have in_use") assert.not_nil(stats.idle, "should have idle") diff --git a/tests/app/src/test/wasm/call_wasi_http_wasm.lua b/tests/app/src/test/wasm/call_wasi_http_wasm.lua index b2221ee31..0a760a3f3 100644 --- a/tests/app/src/test/wasm/call_wasi_http_wasm.lua +++ b/tests/app/src/test/wasm/call_wasi_http_wasm.lua @@ -4,7 +4,7 @@ local assert = require("assert2") local http = require("http_client") local function main() - local res, err = http.post("http://localhost:8085/wasm/greet", { + local res, err = http.post("http://localhost:18085/wasm/greet", { body = "Wippy", headers = { ["Content-Type"] = "text/plain" diff --git a/tests/app/src/test/websocket/echo_server/test.lua b/tests/app/src/test/websocket/echo_server/test.lua index 0bd835d38..df62b6be9 100644 --- a/tests/app/src/test/websocket/echo_server/test.lua +++ b/tests/app/src/test/websocket/echo_server/test.lua @@ -9,7 +9,7 @@ local json = require("json") local function main() -- Connect to local WebSocket echo server via wsrelay with timeout - local client, err = websocket.connect("ws://localhost:8085/ws/echo", { + local client, err = websocket.connect("ws://localhost:18085/ws/echo", { dial_timeout = 3 }) diff --git a/tests/app/src/uploads/test.lua b/tests/app/src/uploads/test.lua index fe3fd620f..dcbb68be3 100644 --- a/tests/app/src/uploads/test.lua +++ b/tests/app/src/uploads/test.lua @@ -8,7 +8,7 @@ local function main() local file_content = "Hello from uploaded file!\nThis is line 2.\n" local filename = "test_upload.txt" - local resp, err = http.post("http://localhost:8085/test/upload", { + local resp, err = http.post("http://localhost:18085/test/upload", { files = { { name = "file", diff --git a/tests/app/test.sh b/tests/app/test.sh index f3854fd20..ac8f26307 100755 --- a/tests/app/test.sh +++ b/tests/app/test.sh @@ -8,16 +8,21 @@ mkdir -p /tmp/wippy-gocache /tmp/wippy-gotmp test_log="$(mktemp /tmp/wippy-app-tests.XXXXXX.log)" +# The application suite owns this port. Refuse to run against an unrelated +# listener: otherwise HTTP/WebSocket tests can return plausible but completely +# wrong responses from another local Wippy instance. +if timeout 1 bash -c 'exec 3<>/dev/tcp/127.0.0.1/18085' 2>/dev/null; then + echo "test HTTP port 18085 is already in use" + exit 1 +fi + # Network overlay tests require a running docker-compose stack (socks5-proxy). # Auto-detect the fast SOCKS5 proxy; skip the suite if not listening. : "${SKIP_NETWORK_TESTS:=}" if [ -z "$SKIP_NETWORK_TESTS" ]; then - if ! (exec 3<>/dev/tcp/127.0.0.1/1080) 2>/dev/null; then + if ! timeout 1 bash -c 'exec 3<>/dev/tcp/127.0.0.1/1080' 2>/dev/null; then SKIP_NETWORK_TESTS=1 echo "socks5-proxy not reachable on 127.0.0.1:1080, skipping network tests (run docker-compose up to enable)" - else - exec 3<&- - exec 3>&- fi fi export SKIP_NETWORK_TESTS @@ -26,16 +31,25 @@ export SKIP_NETWORK_TESTS # at 127.0.0.1:9324. Auto-detect and skip the suite if nothing is listening. : "${SKIP_SQS_TESTS:=}" if [ -z "$SKIP_SQS_TESTS" ]; then - if ! (exec 3<>/dev/tcp/127.0.0.1/9324) 2>/dev/null; then + if ! timeout 1 bash -c 'exec 3<>/dev/tcp/127.0.0.1/9324' 2>/dev/null; then SKIP_SQS_TESTS=1 echo "elasticmq not reachable on 127.0.0.1:9324, skipping sqs tests (run docker-compose up elasticmq to enable)" - else - exec 3<&- - exec 3>&- fi fi export SKIP_SQS_TESTS +# The exec.docker application tests use alpine directly. Make the dependency +# explicit and deterministic instead of letting three tests fail later with a +# generic process-start error. +if ! docker info >/dev/null 2>&1; then + echo "docker is required by the exec.docker application tests" + exit 1 +fi +if ! docker image inspect alpine:latest >/dev/null 2>&1; then + echo "alpine:latest is missing; pulling it for exec.docker tests" + docker pull alpine:latest +fi + GOCACHE=/tmp/wippy-gocache GOTMPDIR=/tmp/wippy-gotmp OTEL_SDK_DISABLED=true SKIP_TEMPORAL_TESTS=1 SKIP_CLOUDSTORAGE_TESTS=1 GOEXPERIMENT=jsonv2 \ go run -tags treesitter ../../cmd/wippy test -c -s | tee "$test_log"