From c5f169ec303a70707bc48fbece9b52cc90a48de0 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Mon, 31 Aug 2026 22:08:11 +0200 Subject: Add CI-runnable HTTP test fixture on the testsrvr/testclnt harness Drives socket.http.request against fully scripted, controlled raw HTTP responses over a real socket by reusing the existing testsrvr.lua remote-execution server -- no Apache, no Docker, no third-party hosts. Proves the fixture with a plain-GET smoke test, a chunked-transfer- encoding smoke test, and a test asserting a response body delivered across separately-timed writes (the incremental-delivery capability the upcoming SSE tests need). Wires the new test into the CI regression step. Claude-Session: https://claude.ai/code/session_01SW789SftppghLPVXYbWsGi --- .github/workflows/build.yml | 2 + test/httpfixture.lua | 104 ++++++++++++++++++++++++++++++++++++++++++++ test/httpfixturetest.lua | 68 +++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 test/httpfixture.lua create mode 100644 test/httpfixturetest.lua diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96564fb..a81649a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,8 @@ jobs: lua hello.lua lua testsrvr.lua > /dev/null & lua testclnt.lua + lua testsrvr.lua > /dev/null & + lua httpfixturetest.lua lua stufftest.lua lua excepttest.lua lua test_bind.lua diff --git a/test/httpfixture.lua b/test/httpfixture.lua new file mode 100644 index 0000000..3ea0737 --- /dev/null +++ b/test/httpfixture.lua @@ -0,0 +1,104 @@ +-- Test fixture for exercising socket.http.request against a fully +-- controlled, scripted HTTP response -- no Apache, no Docker, no +-- third-party hosts. Built on top of the existing testsrvr.lua remote- +-- execution server (see test/testclnt.lua for the `remote()` protocol this +-- reuses): a control connection sends Lua snippets for testsrvr.lua to +-- `load()` and run, which is how it's told to accept the connection that +-- socket.http.request makes and write raw response bytes back to it. +local socket = require("socket") + +local M = {} + +M.host = "localhost" +M.port = "8383" + +-- Connects to a running testsrvr.lua as a control channel and returns a +-- `remote(fmt, ...)` function that scripts a Lua snippet to run on the +-- server. Mirrors test/testclnt.lua's own remote(): the snippet is +-- squashed onto a single line (embedded newlines become ';', runs of +-- whitespace collapse to one space) since the control channel is +-- line-oriented, and the ack sent back is only proof the server received +-- the command, not that it finished running it -- so a snippet that blocks +-- (e.g. server:accept()) doesn't stall the caller. +function M.connect(host, port) + local control = assert(socket.connect(host or M.host, port or M.port)) + control:setoption("tcp-nodelay", true) + local function remote(fmt, ...) + local s = string.format(fmt, ...) + s = string.gsub(s, "\n", ";") + s = string.gsub(s, "%s+", " ") + s = string.gsub(s, "^%s*", "") + assert(control:send(s .. "\n")) + control:receive() + end + return control, remote +end + +-- Turns a raw Lua string into a single-line, double-quoted Lua string +-- literal safe to embed in a remote()-scripted snippet: escapes backslash, +-- quote, and every whitespace control character so no byte in the +-- *encoded* text is itself whitespace. remote() collapses whitespace runs +-- and turns newlines into ';', which would otherwise corrupt raw response +-- bytes (status lines/headers/chunked framing all rely on literal CRLF). +local function quote(s) + s = string.gsub(s, "\\", "\\\\") + s = string.gsub(s, "\"", "\\\"") + s = string.gsub(s, "\r", "\\r") + s = string.gsub(s, "\n", "\\n") + s = string.gsub(s, "\t", "\\t") + return "\"" .. s .. "\"" +end + +-- Scripts the server to accept one connection (into the shared `data` +-- global, mirroring test/testclnt.lua's reconnect()), write a sequence of +-- raw byte chunks to it, then close it. `chunks` is an array of either +-- plain strings, or {body, delay = seconds} tables -- the delay (via +-- socket.sleep) is applied before sending that chunk, so callers can prove +-- incremental/partial delivery instead of one atomic send. +function M.accept_and_send(remote, chunks) + local parts = { + "if data then data:close() data = nil end", + "data = server:accept()", + "data:setoption(\"tcp-nodelay\", true)", + } + for _, chunk in ipairs(chunks) do + local body, delay + if type(chunk) == "table" then + body, delay = chunk[1], chunk.delay + else + body = chunk + end + if delay then + parts[#parts + 1] = string.format("socket.sleep(%f)", delay) + end + parts[#parts + 1] = "data:send(" .. quote(body) .. ")" + end + parts[#parts + 1] = "data:close() data = nil" + remote(table.concat(parts, "\n")) +end + +-- Builds a raw HTTP/1.1 response (status line + headers + body) as a +-- single CRLF-framed string, ready to hand to accept_and_send. +function M.response(status, headers, body) + local lines = { "HTTP/1.1 " .. status } + for _, h in ipairs(headers or {}) do + lines[#lines + 1] = h + end + lines[#lines + 1] = "" + lines[#lines + 1] = body or "" + return table.concat(lines, "\r\n") +end + +-- Encodes a list of body pieces as HTTP chunked-transfer-encoding, ending +-- with the terminating zero-size chunk. Saves callers from hand-rolling +-- the "\r\n\r\n" framing for chunked test bodies. +function M.chunked(pieces) + local out = {} + for _, piece in ipairs(pieces) do + out[#out + 1] = string.format("%x\r\n%s\r\n", #piece, piece) + end + out[#out + 1] = "0\r\n\r\n" + return table.concat(out) +end + +return M diff --git a/test/httpfixturetest.lua b/test/httpfixturetest.lua new file mode 100644 index 0000000..89dfd90 --- /dev/null +++ b/test/httpfixturetest.lua @@ -0,0 +1,68 @@ +-- Smoke tests proving out the httpfixture.lua harness: drive +-- socket.http.request against fully scripted, controlled raw HTTP +-- responses over a real socket via the testsrvr.lua remote-execution +-- server -- no Apache, no Docker, no third-party hosts. Later SSE tests +-- build on this same fixture for real-socket, incremental-delivery +-- coverage. +local http = require("socket.http") +local fixture = require("httpfixture") + +dofile("testsupport.lua") + +local url = "http://" .. fixture.host .. ":" .. fixture.port .. "/" + +local control, remote = fixture.connect() + +io.write("testing plain GET with a known body: ") +do + local body = "hello, luasocket" + local response = fixture.response("200 OK", { + "Content-Length: " .. #body, + "Connection: close", + }, body) + fixture.accept_and_send(remote, { response }) + + local respbody, code = assert(http.request(url .. "get")) + assert(respbody == body, "body mismatch: " .. tostring(respbody)) + assert(code == 200, "status code mismatch: " .. tostring(code)) +end +print("ok") + +io.write("testing chunked-transfer-encoded body: ") +do + local pieces = { "Hello, ", "chunked ", "world!" } + local response = fixture.response("200 OK", { + "Transfer-Encoding: chunked", + "Connection: close", + }, fixture.chunked(pieces)) + fixture.accept_and_send(remote, { response }) + + local respbody, code = assert(http.request(url .. "chunked")) + assert(respbody == table.concat(pieces), "body mismatch: " .. tostring(respbody)) + assert(code == 200, "status code mismatch: " .. tostring(code)) +end +print("ok") + +io.write("testing a body delivered across separately-timed writes: ") +do + local part1, part2 = "partial-", "delivery" + local body = part1 .. part2 + local headersblock = fixture.response("200 OK", { + "Content-Length: " .. #body, + "Connection: close", + }, "") + fixture.accept_and_send(remote, { + headersblock, + { part1, delay = 0.2 }, + part2, + }) + + local respbody, code = assert(http.request(url .. "slow")) + assert(respbody == body, "body mismatch: " .. tostring(respbody)) + assert(code == 200, "status code mismatch: " .. tostring(code)) +end +print("ok") + +remote("os.exit()") + +print("the library passed all tests") -- cgit v1.2.3-55-g6feb