-
Notifications
You must be signed in to change notification settings - Fork 1
Add E2E testing framework and basic scenarios #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| using System.Collections.Generic; | ||
| using System.Threading.Tasks; | ||
| using Newtonsoft.Json; | ||
|
|
||
| namespace Yllibed.HttpServer.Json.Tests; | ||
|
|
||
| [TestClass] | ||
| public class JsonHandlerBaseMoreFixture : FixtureBase | ||
| { | ||
| private sealed class ThrowingHandler : JsonHandlerBase<object> | ||
| { | ||
| public ThrowingHandler(string method, string path) : base(method, path) { } | ||
|
|
||
| protected override Task<(object result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary<string, string[]> queryParameters) | ||
| { | ||
| throw new InvalidOperationException("boom"); | ||
| } | ||
| } | ||
|
|
||
| private sealed class EchoHandler : JsonHandlerBase<object> | ||
| { | ||
| public EchoHandler(string method, string path) : base(method, path) { } | ||
|
|
||
| protected override Task<(object result, ushort statusCode)> ProcessRequest(CancellationToken ct, string relativePath, IDictionary<string, string[]> queryParameters) | ||
| { | ||
| return Task.FromResult<(object, ushort)>((new { ok = true }, 200)); | ||
| } | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public async Task JsonHandler_WhenProcessThrows_ShouldReturn500WithPlainText() | ||
| { | ||
| using var server = new Server(); | ||
| server.RegisterHandler(new ThrowingHandler("GET", "/err")); | ||
| var (uri4, _) = server.Start(); | ||
| var requestUri = new Uri(uri4, "err"); | ||
|
|
||
| using var client = new HttpClient(); | ||
| var response = await client.GetAsync(requestUri, CT); | ||
| response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); | ||
| response.Content.Headers.ContentType!.MediaType.Should().Be("text/plain"); | ||
| var content = await response.Content.ReadAsStringAsync(CT); | ||
| content.Should().Contain("Error processing request"); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public async Task JsonHandler_WrongMethod_ShouldNotHandle_AndServerReturns404() | ||
| { | ||
| using var server = new Server(); | ||
| server.RegisterHandler(new EchoHandler("POST", "/j")); | ||
| var (uri4, _) = server.Start(); | ||
| var requestUri = new Uri(uri4, "j"); | ||
|
|
||
| using var client = new HttpClient(); | ||
| var response = await client.GetAsync(requestUri, CT); | ||
| response.StatusCode.Should().Be(HttpStatusCode.NotFound); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFramework>net8.0</TargetFramework> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <Nullable>enable</Nullable> | ||
| </PropertyGroup> | ||
| <ItemGroup> | ||
| <ProjectReference Include="..\..\Yllibed.HttpServer\Yllibed.HttpServer.csproj" /> | ||
| </ItemGroup> | ||
| </Project> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| using Yllibed.HttpServer; | ||
| using Yllibed.HttpServer.Handlers; | ||
| using Yllibed.HttpServer.Sse; | ||
|
|
||
| // E2E = End-to-End. This small app is started by the E2E tests | ||
| // to spin up a real HTTP server and verify, via a real client, that the /ping endpoint | ||
| // responds correctly. Here we deliberately pick a dynamic port to showcase dynamic port discovery. | ||
| var server = new Server(); // dynamic port | ||
| server.RegisterHandler(new StaticHandler("ping", "text/plain", "pong")); | ||
|
|
||
| // Register an SSE endpoint under /sse/js that emits a single event | ||
| var sseRoute = new RelativePathHandler("sse"); | ||
| sseRoute.RegisterHandler(new E2EApp.JsSseHandler()); | ||
| server.RegisterHandler(sseRoute); | ||
|
|
||
| var (uri4, uri6) = server.Start(); | ||
|
|
||
| // Expose the dynamically selected port in a machine-readable way for the Node E2E runner | ||
| Console.WriteLine("PORT={0}", uri4.Port); | ||
| Console.WriteLine($"E2EApp started on {uri4} and {uri6}"); | ||
| Console.WriteLine("READY"); | ||
|
|
||
| // Keep the app alive until killed | ||
| await Task.Delay(Timeout.InfiniteTimeSpan); | ||
|
|
||
| // Local SSE handler used only by the E2E app | ||
| namespace E2EApp | ||
| { | ||
| internal sealed class JsSseHandler : SseHandler | ||
| { | ||
| protected override bool ShouldHandle(IHttpServerRequest request, string relativePath) | ||
| => base.ShouldHandle(request, relativePath) && relativePath == "/js"; | ||
|
|
||
| protected override SseOptions? GetOptions(IHttpServerRequest request, string relativePath) | ||
| => new SseOptions { AutoFlush = true, HeartbeatInterval = TimeSpan.Zero }; | ||
|
|
||
| protected override async Task HandleSseSession(ISseSession sse, CancellationToken ct) | ||
| { | ||
| // Emit a simple event with a fixed id/name and JSON payload | ||
| // Emit compact JSON manually to avoid extra project references | ||
| await sse.SendEventAsync("{\"A\":1,\"B\":\"x\"}", eventName: "obj", id: "e2e-1", ct: ct).ConfigureAwait(false); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| using System.Diagnostics; | ||
|
|
||
| namespace Yllibed.HttpServer.Tests; | ||
|
|
||
| [TestClass] | ||
| public sealed class E2E_NodeFixture : FixtureBase | ||
| { | ||
| [TestMethod] | ||
| public void E2E_NodeScript_ShouldSucceed() | ||
| { | ||
| // Verify that Node.js is available (otherwise mark the test as Inconclusive) | ||
| if (!IsCommandAvailable("node")) | ||
| { | ||
| Assert.Inconclusive("Node.js (node) is not available on PATH. Install Node.js to run this E2E test."); | ||
| } | ||
|
|
||
| var solutionRoot = GetSolutionRoot(); | ||
| // Script now located under the test project folder | ||
| var scriptPath = Path.Combine(solutionRoot, "Yllibed.HttpServer.Tests", "e2e", "run-e2e.js"); | ||
| if (!File.Exists(scriptPath)) | ||
| { | ||
| // Fallback: when tests run from within the test project root already | ||
| scriptPath = Path.Combine(solutionRoot, "e2e", "run-e2e.js"); | ||
| } | ||
| File.Exists(scriptPath).Should().BeTrue("E2E script must exist at {0}", scriptPath); | ||
|
|
||
| var psi = new ProcessStartInfo | ||
| { | ||
| FileName = "node", | ||
| Arguments = scriptPath, | ||
| WorkingDirectory = Path.GetDirectoryName(scriptPath)!, | ||
| UseShellExecute = false, | ||
| RedirectStandardOutput = true, | ||
| RedirectStandardError = true, | ||
| CreateNoWindow = true, | ||
| }; | ||
|
|
||
| using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; | ||
| var stdout = new System.Text.StringBuilder(); | ||
| var stderr = new System.Text.StringBuilder(); | ||
| proc.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); }; | ||
| proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); }; | ||
|
|
||
| proc.Start(); | ||
| proc.BeginOutputReadLine(); | ||
| proc.BeginErrorReadLine(); | ||
|
|
||
| // Give a generous timeout (same order as the script, which times out after ~10s) | ||
| var exited = proc.WaitForExit(30000); | ||
| if (!exited) | ||
| { | ||
| try { proc.Kill(entireProcessTree: true); } catch (Exception) { /* ignore */ } | ||
| Assert.Fail("E2E Node script did not exit within timeout.\nSTDOUT:\n{0}\nSTDERR:\n{1}", stdout.ToString(), stderr.ToString()); | ||
| } | ||
|
|
||
| // Ensure we read all remaining output | ||
| proc.WaitForExit(); | ||
|
|
||
| if (proc.ExitCode != 0) | ||
| { | ||
| Assert.Fail("E2E Node script failed with exit code {0}.\nSTDOUT:\n{1}\nSTDERR:\n{2}", proc.ExitCode, stdout.ToString(), stderr.ToString()); | ||
| } | ||
| } | ||
|
|
||
| private static bool IsCommandAvailable(string command) | ||
| { | ||
| try | ||
| { | ||
| var psi = new ProcessStartInfo | ||
| { | ||
| FileName = command, | ||
| Arguments = "--version", | ||
| UseShellExecute = false, | ||
| RedirectStandardOutput = true, | ||
| RedirectStandardError = true, | ||
| CreateNoWindow = true, | ||
| }; | ||
| using var p = Process.Start(psi); | ||
| if (p is null) return false; | ||
| p.WaitForExit(5000); | ||
| return p.ExitCode == 0; | ||
| } | ||
| catch | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| private static string GetSolutionRoot() | ||
| { | ||
| // Heuristic: tests usually run with a WorkingDirectory under the test project's bin folder. | ||
| // Walk up until we find the solution file. | ||
| var dir = new DirectoryInfo(Environment.CurrentDirectory); | ||
| for (int i = 0; i < 10 && dir is not null; i++, dir = dir.Parent!) | ||
|
||
| { | ||
| if (File.Exists(Path.Combine(dir.FullName, "Yllibed.HttpServer.slnx"))) | ||
| { | ||
| return dir.FullName; | ||
| } | ||
| } | ||
| // Fallback: base directory if available | ||
| return AppContext.BaseDirectory; // best effort | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,140 @@ | ||||||||||||||
| /* | ||||||||||||||
| E2E (End-to-End) = tests that validate the whole system from the | ||||||||||||||
| user/consumer point of view: we start a real server app, send a real | ||||||||||||||
| HTTP request, and assert the response. | ||||||||||||||
|
|
||||||||||||||
| Here, this Node script acts as the E2E runner: | ||||||||||||||
| - it starts the .NET E2EApp (dotnet run) that hosts our HTTP server; | ||||||||||||||
| - the app prints a machine-readable line PORT=<number> followed by READY; | ||||||||||||||
| - the script waits for READY and captures the port; | ||||||||||||||
| - it performs a GET /ping to http://127.0.0.1:<port>/ping and verifies status/content/body; | ||||||||||||||
| - it connects to an SSE endpoint /sse/js and validates the first event (id, event, data); | ||||||||||||||
| - it then terminates the spawned process cleanly. | ||||||||||||||
|
|
||||||||||||||
| These tests differ from unit/integration tests because they exercise the | ||||||||||||||
| entire chain: client → network → server → handler → response. | ||||||||||||||
| */ | ||||||||||||||
| // Simple Node E2E script to validate the server works end-to-end | ||||||||||||||
| const { spawn } = require('child_process'); | ||||||||||||||
| const http = require('http'); | ||||||||||||||
| const path = require('path'); | ||||||||||||||
|
|
||||||||||||||
| function waitForReady(proc, timeoutMs = 10000) { | ||||||||||||||
| return new Promise((resolve, reject) => { | ||||||||||||||
| let ready = false; | ||||||||||||||
| let port = null; | ||||||||||||||
| const timer = setTimeout(() => { | ||||||||||||||
| if (!ready) reject(new Error('Timeout waiting for READY')); | ||||||||||||||
| }, timeoutMs); | ||||||||||||||
|
|
||||||||||||||
| proc.stdout.on('data', (data) => { | ||||||||||||||
| const txt = data.toString(); | ||||||||||||||
| process.stdout.write(txt); | ||||||||||||||
| // In case multiple lines are delivered at once, split and scan each | ||||||||||||||
| for (const line of txt.split(/\r?\n/)) { | ||||||||||||||
| if (!line) continue; | ||||||||||||||
| if (line.startsWith('PORT=')) { | ||||||||||||||
| const v = parseInt(line.substring('PORT='.length), 10); | ||||||||||||||
| if (!Number.isNaN(v) && v > 0) port = v; | ||||||||||||||
| } | ||||||||||||||
| if (line.includes('READY')) { | ||||||||||||||
| ready = true; | ||||||||||||||
| clearTimeout(timer); | ||||||||||||||
| if (!port) return reject(new Error('READY received but no PORT=<n> captured')); | ||||||||||||||
| return resolve(port); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| }); | ||||||||||||||
| proc.stderr.on('data', (data) => process.stderr.write(data.toString())); | ||||||||||||||
| proc.on('exit', (code) => { | ||||||||||||||
| if (!ready) reject(new Error(`E2E app exited before ready. Code: ${code}`)); | ||||||||||||||
| }); | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function httpGet(url) { | ||||||||||||||
| return new Promise((resolve, reject) => { | ||||||||||||||
| const req = http.get(url, (res) => { | ||||||||||||||
| let body = ''; | ||||||||||||||
| res.setEncoding('utf8'); | ||||||||||||||
| res.on('data', (chunk) => (body += chunk)); | ||||||||||||||
| res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers })); | ||||||||||||||
| }); | ||||||||||||||
| req.on('error', reject); | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| (async () => { | ||||||||||||||
| // Compute project path relative to this script location | ||||||||||||||
| const scriptDir = __dirname; | ||||||||||||||
| const testProjectRoot = path.resolve(scriptDir, '..'); | ||||||||||||||
| const e2eAppProj = path.resolve(testProjectRoot, 'E2EApp'); | ||||||||||||||
|
|
||||||||||||||
| const app = spawn('dotnet', ['run', '--project', e2eAppProj, '-c', 'Release'], { cwd: testProjectRoot, env: process.env }); | ||||||||||||||
| try { | ||||||||||||||
| const port = await waitForReady(app); | ||||||||||||||
|
|
||||||||||||||
| const res = await httpGet(`http://127.0.0.1:${port}/ping`); | ||||||||||||||
| if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`); | ||||||||||||||
| if ((res.headers['content-type']||'').split(';')[0] !== 'text/plain') throw new Error(`Unexpected content-type: ${res.headers['content-type']}`); | ||||||||||||||
| if (res.body !== 'pong') throw new Error(`Unexpected body: ${res.body}`); | ||||||||||||||
|
|
||||||||||||||
| // SSE: connect and validate first event | ||||||||||||||
| await new Promise((resolve, reject) => { | ||||||||||||||
| const req = http.get(`http://127.0.0.1:${port}/sse/js`, (res2) => { | ||||||||||||||
| const ct = (res2.headers['content-type']||'').split(';')[0]; | ||||||||||||||
| if (res2.statusCode !== 200) return reject(new Error(`SSE unexpected status: ${res2.statusCode}`)); | ||||||||||||||
| if (ct !== 'text/event-stream') return reject(new Error(`SSE unexpected content-type: ${res2.headers['content-type']}`)); | ||||||||||||||
| res2.setEncoding('utf8'); | ||||||||||||||
| let buffer = ''; | ||||||||||||||
| let got = false; | ||||||||||||||
| const timer = setTimeout(() => { | ||||||||||||||
| if (!got) reject(new Error('Timeout waiting for first SSE event')); | ||||||||||||||
| }, 5000); | ||||||||||||||
| res2.on('data', (chunk) => { | ||||||||||||||
| buffer += chunk; | ||||||||||||||
| // Parse by lines, server may send multiple events or heartbeats | ||||||||||||||
| let idx; | ||||||||||||||
| while ((idx = buffer.indexOf('\n')) >= 0) { | ||||||||||||||
| const line = buffer.slice(0, idx).replace(/\r$/, ''); | ||||||||||||||
| buffer = buffer.slice(idx + 1); | ||||||||||||||
| if (!line) { | ||||||||||||||
| // blank line terminates an event; keep collecting in outer scope if needed | ||||||||||||||
| continue; | ||||||||||||||
| } | ||||||||||||||
| // Collect fields | ||||||||||||||
| // We expect at least: id:e2e-1, event:obj, data:{"A":1,"B":"x"} | ||||||||||||||
| if (line.startsWith('id:')) { | ||||||||||||||
| const id = line.substring(3).trim(); | ||||||||||||||
| if (id !== 'e2e-1') return reject(new Error(`SSE unexpected id: ${id}`)); | ||||||||||||||
| } else if (line.startsWith('event:')) { | ||||||||||||||
| const ev = line.substring(6).trim(); | ||||||||||||||
| if (ev !== 'obj') return reject(new Error(`SSE unexpected event: ${ev}`)); | ||||||||||||||
| } else if (line.startsWith('data:')) { | ||||||||||||||
| const data = line.substring(5).trim(); | ||||||||||||||
| if (data !== '{"A":1,"B":"x"}') return reject(new Error(`SSE unexpected data: ${data}`)); | ||||||||||||||
| got = true; | ||||||||||||||
| clearTimeout(timer); | ||||||||||||||
| resolve(); | ||||||||||||||
| req.destroy(); | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| }); | ||||||||||||||
| res2.on('error', reject); | ||||||||||||||
| }); | ||||||||||||||
| req.on('error', reject); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| console.log('E2E OK'); | ||||||||||||||
| } catch (e) { | ||||||||||||||
| console.error('E2E FAILED:', e); | ||||||||||||||
| process.exitCode = 1; | ||||||||||||||
| } finally { | ||||||||||||||
| if (process.platform === 'win32') { | ||||||||||||||
| spawn('taskkill', ['/PID', String(app.pid), '/T', '/F']); | ||||||||||||||
|
||||||||||||||
| spawn('taskkill', ['/PID', String(app.pid), '/T', '/F']); | |
| const { spawnSync } = require('child_process'); | |
| const result = spawnSync('taskkill', ['/PID', String(app.pid), '/T', '/F']); | |
| if (result.error || result.status !== 0) { | |
| console.error('Failed to kill process with taskkill:', result.error || result.stderr.toString()); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The comment formatting is inconsistent with the indentation pattern used in the rest of the file. This comment should be aligned with the same indentation as the code it describes.