Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ jobs:
if-no-files-found: error

- name: Test
run: dotnet test Yllibed.HttpServer.slnx /p:Configuration=Release --no-build
run: dotnet test Yllibed.HttpServer.slnx /p:Configuration=Release --no-build --collect "XPlat Code Coverage"


publish:
if: startsWith(github.ref, 'refs/heads/master')
Expand Down
58 changes: 58 additions & 0 deletions Yllibed.HttpServer.Json.Tests/JsonHandlerBaseMoreFixture.cs
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);
}
}
11 changes: 11 additions & 0 deletions Yllibed.HttpServer.Tests/E2EApp/E2EApp.csproj
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>
44 changes: 44 additions & 0 deletions Yllibed.HttpServer.Tests/E2EApp/Program.cs
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
Copy link

Copilot AI Sep 6, 2025

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.

Suggested change
// Emit compact JSON manually to avoid extra project references
// Emit compact JSON manually to avoid extra project references

Copilot uses AI. Check for mistakes.
await sse.SendEventAsync("{\"A\":1,\"B\":\"x\"}", eventName: "obj", id: "e2e-1", ct: ct).ConfigureAwait(false);
}
}
}
104 changes: 104 additions & 0 deletions Yllibed.HttpServer.Tests/E2E_NodeFixture.cs
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!)
Copy link

Copilot AI Sep 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The magic number 10 for directory traversal limit should be extracted as a named constant to make the code more maintainable and self-documenting.

Copilot uses AI. Check for mistakes.
{
if (File.Exists(Path.Combine(dir.FullName, "Yllibed.HttpServer.slnx")))
{
return dir.FullName;
}
}
// Fallback: base directory if available
return AppContext.BaseDirectory; // best effort
}
}
6 changes: 6 additions & 0 deletions Yllibed.HttpServer.Tests/Yllibed.HttpServer.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@
<PackageReference Include="System.ValueTuple" />
<PackageReference Update="Microsoft.NET.Test.Sdk" VersionOverride="17.14.1" />
</ItemGroup>
<ItemGroup>
<Compile Remove="E2EApp\**\*.cs" />
<Compile Remove="E2EApp\**\*.fs" />
<None Include="E2EApp\**\*" Exclude="**\bin\**;**\obj\**" />
<None Include="e2e\**\*" Exclude="**\bin\**;**\obj\**" />
</ItemGroup>
</Project>
140 changes: 140 additions & 0 deletions Yllibed.HttpServer.Tests/e2e/run-e2e.js
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']);
Copy link

Copilot AI Sep 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spawned taskkill process is not awaited or handled for errors. If taskkill fails, the child process may remain running. Consider using synchronous execution or proper error handling.

Suggested change
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());
}

Copilot uses AI. Check for mistakes.
} else {
app.kill('SIGKILL');
}
}
})();
Loading