diff --git a/.github/workflows/kernel-tests.yml b/.github/workflows/kernel-tests.yml new file mode 100644 index 00000000..ab620417 --- /dev/null +++ b/.github/workflows/kernel-tests.yml @@ -0,0 +1,180 @@ +name: Kernel Tests + +on: + push: + branches: [ "main" ] + paths: + - 'src/**' + - 'tests/Cosmos.TestRunner.**' + - 'tests/Kernels/**' + - '.github/workflows/kernel-tests.yml' + pull_request: + branches: [ "**" ] + paths: + - 'src/**' + - 'tests/Cosmos.TestRunner.**' + - 'tests/Kernels/**' + - '.github/workflows/kernel-tests.yml' + workflow_dispatch: + inputs: + architecture: + description: 'Architecture to test' + required: false + default: 'all' + type: choice + options: + - all + - x64 + - arm64 + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + kernel-tests: + name: Kernel Tests - ${{ matrix.arch }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + dotnet-version: [ 9.0.x ] + arch: [ x64, arm64 ] + include: + - arch: x64 + rid: linux-x64 + qemu-package: qemu-system-x86 + timeout: 60 + - arch: arm64 + rid: linux-arm64 + qemu-package: qemu-system-arm + qemu-firmware: qemu-efi-aarch64 + timeout: 90 + + steps: + - name: ๐Ÿ“ฅ Checkout Repository + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: ๐Ÿ› ๏ธ Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: ๐Ÿ—‘๏ธ Clear NuGet Cache + run: dotnet nuget locals all --clear + + - name: ๐Ÿ“ฆ Build and Install Cosmos Packages (${{ matrix.arch }}) + run: ./.devcontainer/postCreateCommand.sh ${{ matrix.arch }} + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + + - name: ๐Ÿ”ง Install QEMU and Build Tools + run: | + sudo apt-get update + sudo apt-get install -y ${{ matrix.qemu-package }} xorriso lld + if [ "${{ matrix.arch }}" = "arm64" ]; then + sudo apt-get install -y ${{ matrix.qemu-firmware }} gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu + else + sudo apt-get install -y yasm + fi + + - name: ๐Ÿ” Verify QEMU Installation + run: | + if [ "${{ matrix.arch }}" = "arm64" ]; then + qemu-system-aarch64 --version + else + qemu-system-x86_64 --version + fi + + - name: ๐Ÿ”จ Build Test Runner Engine + run: dotnet build tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj -c Debug --verbosity normal + + - name: ๐Ÿงช Run HelloWorld Kernel Tests + id: run-tests + run: | + echo "Running kernel tests for ${{ matrix.arch }}..." + dotnet run --project tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj \ + --no-build \ + -- tests/Kernels/Cosmos.Kernel.Tests.HelloWorld \ + ${{ matrix.arch }} \ + ${{ matrix.timeout }} \ + test-results-${{ matrix.arch }}.xml \ + ci + timeout-minutes: 5 + continue-on-error: false + + - name: ๐Ÿ“Š Display Test Summary + if: always() + run: | + if [ -f "test-results-${{ matrix.arch }}.xml" ]; then + echo "โœ… Test results file created" + grep -o 'tests="[^"]*"' test-results-${{ matrix.arch }}.xml || echo "Could not parse test count" + grep -o 'failures="[^"]*"' test-results-${{ matrix.arch }}.xml || echo "Could not parse failure count" + else + echo "โŒ Test results file not found" + fi + + - name: ๐Ÿ“Š Publish Test Results to PR + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: test-results-${{ matrix.arch }}.xml + check_name: Kernel Test Results (${{ matrix.arch }}) + comment_title: ๐Ÿงช Cosmos Kernel Tests (${{ matrix.arch }}) + comment_mode: always + compare_to_earlier_commit: false + + - name: ๐Ÿ“ค Upload Test Results XML + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.arch }}-${{ github.run_number }} + path: test-results-${{ matrix.arch }}.xml + retention-days: 30 + + - name: ๐Ÿ“ค Upload UART Debug Log + uses: actions/upload-artifact@v4 + if: always() + with: + name: uart-log-${{ matrix.arch }}-${{ github.run_number }} + path: uart-output.log + retention-days: 7 + + - name: ๐Ÿ“ค Upload Test Kernel ISO + uses: actions/upload-artifact@v4 + if: always() + with: + name: HelloWorld-Test-ISO-${{ matrix.arch }}-${{ github.run_number }} + path: | + tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/output-${{ matrix.arch }}/*.iso + tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/output-${{ matrix.arch }}/*.elf + retention-days: 7 + + test-summary: + name: Test Summary + runs-on: ubuntu-latest + needs: kernel-tests + if: always() + steps: + - name: ๐Ÿ“Š Generate Summary + run: | + echo "# ๐Ÿงช Kernel Test Suite Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Architecture | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------------|--------|" >> $GITHUB_STEP_SUMMARY + + if [ "${{ needs.kernel-tests.result }}" == "success" ]; then + echo "| All | โœ… Passed |" >> $GITHUB_STEP_SUMMARY + else + echo "| All | โŒ Failed |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Run ID:** ${{ github.run_id }}" >> $GITHUB_STEP_SUMMARY + echo "**Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index b03a9c39..b63c5bda 100644 --- a/.gitignore +++ b/.gitignore @@ -407,3 +407,44 @@ output output-x64/ output-arm64/ uart-arm64.log + +# Logs +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dev-debug.log +# Dependency directories +# Environment variables +.env +# Editor directories and files +.idea +.vscode +*.ntvs* +*.njsproj +*.sln +*.sw? +# OS specific +.DS_Store + +# Task Master - AI tool configs (keep core task files for test runner project) +.env.example +.clinerules/ +.gemini/ +.github/instructions/ +.kilo/ +.kiro/ +.roo/ +.trae/ +.windsurf/ +.zed/ +.taskmaster/AGENT.md +.taskmaster/templates/ +AGENT.md +AGENTS.md +GEMINI.md +opencode.json +.rules +.cursor/ +.vscode/mcp.json +output-helloworld/ diff --git a/.vscode/launch.json b/.vscode/launch.json index e874852c..39742fb1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -85,6 +85,40 @@ } ], "launchCompleteCommand": "None" + }, + { + "name": "Debug Test Runner (HelloWorld x64)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "Build Test Runner", + "program": "${workspaceFolder}/artifacts/bin/Cosmos.TestRunner.Engine/debug/Cosmos.TestRunner.Engine.dll", + "args": [ + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld", + "x64", + "60", + "${workspaceFolder}/test-results.xml", + "dev" + ], + "cwd": "${workspaceFolder}", + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": "Debug Test Runner (HelloWorld ARM64)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "Build Test Runner", + "program": "${workspaceFolder}/artifacts/bin/Cosmos.TestRunner.Engine/debug/Cosmos.TestRunner.Engine.dll", + "args": [ + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld", + "arm64", + "90", + "${workspaceFolder}/test-results-arm64.xml", + "dev" + ], + "cwd": "${workspaceFolder}", + "console": "internalConsole", + "stopAtEntry": false } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c224a5d2..95a71c39 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -173,6 +173,173 @@ "presentation": { "reveal": "never" } + }, + { + "label": "Build Test Runner", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj" + ], + "group": "build", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "problemMatcher": "$msCompile" + }, + { + "label": "Run Test: HelloWorld (x64)", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj", + "--", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld", + "x64", + "60", + "${workspaceFolder}/test-results.xml", + "ci" + ], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "Run Test: HelloWorld (x64, Console Only)", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj", + "--", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld", + "x64", + "60" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "Run Test: HelloWorld (ARM64)", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj", + "--", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld", + "arm64", + "90", + "${workspaceFolder}/test-results-arm64.xml", + "ci" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "Dev Test: HelloWorld (x64)", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj", + "--", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld", + "x64", + "60", + "-", + "dev" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "Build HelloWorld Test Kernel (x64)", + "type": "shell", + "command": "dotnet", + "args": [ + "publish", + "-c", + "Debug", + "-r", + "linux-x64", + "-p:DefineConstants=ARCH_X64", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Cosmos.Kernel.Tests.HelloWorld.csproj", + "-o", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/output-x64" + ], + "group": "build", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "problemMatcher": "$msCompile" + }, + { + "label": "Build HelloWorld Test Kernel (ARM64)", + "type": "shell", + "command": "dotnet", + "args": [ + "publish", + "-c", + "Debug", + "-r", + "linux-arm64", + "-p:DefineConstants=ARCH_ARM64", + "-p:CosmosArch=arm64", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Cosmos.Kernel.Tests.HelloWorld.csproj", + "-o", + "${workspaceFolder}/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/output-arm64" + ], + "group": "build", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "problemMatcher": "$msCompile" + }, + { + "label": "Clean Test Artifacts", + "type": "shell", + "command": "rm", + "args": [ + "-f", + "${workspaceFolder}/test-results*.xml", + "${workspaceFolder}/uart-output.log" + ], + "presentation": { + "reveal": "silent", + "panel": "shared" + } } ] } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b81e86c0..f94cb81e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,59 @@ 2. Ask user for guidance 3. DON'T keep trying random approaches +## ๐ŸŽฏ TASK MASTER UPDATE DISCIPLINE ๐ŸŽฏ + +**CRITICAL: ALWAYS document what you do, but keep it CLEAN, FOCUSED, and MINIMAL** + +**Core Principle:** +- **ALWAYS update tasks/subtasks** as you work - documentation is MANDATORY +- Keep updates **concise and signal-focused** - avoid noise and redundancy +- **Document decisions, blockers, and outcomes** - not obvious steps + +**Update Guidelines:** +- โœ… **DO**: Write concise, technical updates focused ONLY on what's relevant to the task +- โœ… **DO**: Document ALL significant work - findings, decisions, blockers, completions +- โœ… **DO**: Update subtasks as you progress through them (even brief updates like "Complete" or "Found issue with X") +- โœ… **DO**: Use clear, factual language (e.g., "Implemented X, found Y issue, resolved with Z") +- โœ… **DO**: Keep individual updates under 3-4 sentences unless complexity requires more +- โŒ **DON'T**: Add verbose summaries listing every file/class/method you touched +- โŒ **DON'T**: Repeat information already in the task description or subtask titles +- โŒ **DON'T**: Include build output, full file paths, or implementation minutiae unless specifically relevant +- โŒ **DON'T**: Skip documentation - always log what you did, even if brief + +**Examples:** + +**BAD (verbose, redundant, lists everything):** +```bash +tm update-task --id=3 --prompt="Successfully implemented complete output handler system with OutputHandlerBase abstract class, OutputHandlerConsole for colored terminal output, OutputHandlerXml for JUnit CI integration, MultiplexingOutputHandler for simultaneous multiple outputs, and integrated into Engine.cs. Build succeeds with zero warnings/errors. Support for custom handlers via TestConfiguration.OutputHandler and automatic console+XML multiplexing via XmlOutputPath config option." +``` + +**GOOD (focused, documents key outcome):** +```bash +tm update-subtask --id=3.2 --prompt="Complete. Added real-time progress indicators and colored output support." +``` + +**GOOD (documents blocker/decision):** +```bash +tm update-subtask --id=3.4 --prompt="Hit issue with Finalize() method name conflicting with destructor. Renamed to Complete() throughout." +``` + +**When to update:** +- โœ… **ALWAYS**: When completing a subtask (even just "Done" or "Complete") +- โœ… **ALWAYS**: When you hit a blocker or make an architectural decision +- โœ… **ALWAYS**: When you discover something unexpected that affects approach +- โœ… **ALWAYS**: When you change course or deviate from original plan +- โŒ **NEVER**: Skip documenting work - always log progress + +**What to include in updates:** +- โœ… Blockers encountered and how resolved +- โœ… Key decisions made (e.g., "Chose X over Y because...") +- โœ… Unexpected findings (e.g., "Found existing implementation in...") +- โœ… Completion status (e.g., "Complete", "Done", "Working") +- โŒ Lists of files created/modified (unless specifically relevant) +- โŒ Code snippets (unless showing a specific decision point) +- โŒ Build output (unless showing an error you fixed) + ## Project Overview NativeAOT patcher for Cosmos OS - ports the Cosmos plug system and assembly loading to NativeAOT. diff --git a/src/Cosmos.Build.GCC/build/Cosmos.Build.GCC.targets b/src/Cosmos.Build.GCC/build/Cosmos.Build.GCC.targets index 5493ebaf..1a2755e8 100644 --- a/src/Cosmos.Build.GCC/build/Cosmos.Build.GCC.targets +++ b/src/Cosmos.Build.GCC/build/Cosmos.Build.GCC.targets @@ -29,8 +29,8 @@ -O2 -fno-stack-protector -nostdinc -fno-builtin - $(GCCCompilerFlags) -m64 -mcmodel=kernel -fno-PIC -ffreestanding - $(GCCCompilerFlags) -march=armv8-a -mcmodel=large -fno-PIC -ffreestanding + $(GCCCompilerFlags) -DARCH_X64 -m64 -mcmodel=kernel -fno-PIC -ffreestanding + $(GCCCompilerFlags) -DARCH_ARM64 -march=armv8-a -mcmodel=large -fno-PIC -ffreestanding $(GCCCompilerFlags) @(GCCIncludePath->'-I%(Identity)', ' ') diff --git a/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj b/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj new file mode 100644 index 00000000..54665576 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + true + + + + + + + + diff --git a/tests/Cosmos.TestRunner.Engine/Engine.Build.cs b/tests/Cosmos.TestRunner.Engine/Engine.Build.cs new file mode 100644 index 00000000..a69a47ae --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Engine.Build.cs @@ -0,0 +1,124 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace Cosmos.TestRunner.Engine; + +/// +/// Engine build functionality - compiles test kernels to bootable ISOs +/// +public partial class Engine +{ + /// + /// Build the test kernel using dotnet publish + /// + private async Task BuildKernelAsync() + { + if (!Directory.Exists(_config.KernelProjectPath)) + { + throw new DirectoryNotFoundException($"Kernel project not found: {_config.KernelProjectPath}"); + } + + // Find the .csproj file + var csprojFiles = Directory.GetFiles(_config.KernelProjectPath, "*.csproj"); + if (csprojFiles.Length == 0) + { + throw new FileNotFoundException($"No .csproj file found in {_config.KernelProjectPath}"); + } + + string projectFile = csprojFiles[0]; + string projectName = Path.GetFileNameWithoutExtension(projectFile); + + // Determine output directory + string outputDir = _config.OutputDirectory; + if (string.IsNullOrEmpty(outputDir)) + { + outputDir = Path.Combine(_config.KernelProjectPath, $"output-{_config.Architecture}"); + } + + // Setup dotnet publish command + string runtime = _config.Architecture.ToLowerInvariant() switch + { + "x64" => "linux-x64", + "arm64" => "linux-arm64", + _ => throw new ArgumentException($"Unsupported architecture: {_config.Architecture}") + }; + + string defineConstants = _config.Architecture.ToUpperInvariant() switch + { + "X64" => "ARCH_X64", + "ARM64" => "ARCH_ARM64", + _ => throw new ArgumentException($"Unsupported architecture: {_config.Architecture}") + }; + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"publish " + + $"-c {_config.BuildConfiguration} " + + $"-r {runtime} " + + $"-p:DefineConstants=\"{defineConstants}\" " + + $"\"{projectFile}\" " + + $"-o \"{outputDir}\"", + WorkingDirectory = _config.KernelProjectPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Add dotnet tools to PATH + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var homeDir = Environment.GetEnvironmentVariable("HOME") ?? string.Empty; + var dotnetToolsPath = Path.Combine(homeDir, ".dotnet", "tools"); + + if (!pathEnv.Contains(dotnetToolsPath)) + { + startInfo.EnvironmentVariables["PATH"] = $"{pathEnv}:{dotnetToolsPath}"; + } + + using var process = new Process { StartInfo = startInfo }; + + var outputBuilder = new System.Text.StringBuilder(); + var errorBuilder = new System.Text.StringBuilder(); + + process.OutputDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + { + outputBuilder.AppendLine(e.Data); + Console.WriteLine($"[Build] {e.Data}"); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + { + errorBuilder.AppendLine(e.Data); + Console.WriteLine($"[Build Error] {e.Data}"); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + throw new Exception($"Build failed with exit code {process.ExitCode}:\n{errorBuilder}"); + } + + // Find the ISO file + string isoPath = Path.Combine(outputDir, $"{projectName}.iso"); + if (!File.Exists(isoPath)) + { + throw new FileNotFoundException($"ISO file not found after build: {isoPath}"); + } + + return isoPath; + } +} diff --git a/tests/Cosmos.TestRunner.Engine/Engine.cs b/tests/Cosmos.TestRunner.Engine/Engine.cs new file mode 100644 index 00000000..b886264d --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Engine.cs @@ -0,0 +1,202 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using Cosmos.TestRunner.Engine.Hosts; +using Cosmos.TestRunner.Engine.Protocol; +using Cosmos.TestRunner.Engine.OutputHandlers; + +namespace Cosmos.TestRunner.Engine; + +/// +/// Main test runner engine - orchestrates build, launch, monitor, and result collection +/// +public partial class Engine +{ + private readonly TestConfiguration _config; + private readonly IQemuHost _qemuHost; + private readonly OutputHandlerBase _outputHandler; + + public Engine(TestConfiguration config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + + // Select QEMU host based on architecture + _qemuHost = _config.Architecture.ToLowerInvariant() switch + { + "x64" => new QemuX64Host(), + "arm64" => new QemuARM64Host(), + _ => throw new ArgumentException($"Unsupported architecture: {_config.Architecture}") + }; + + // Setup output handler(s) + _outputHandler = SetupOutputHandler(); + } + + private OutputHandlerBase SetupOutputHandler() + { + // If user provided a custom handler, use it + if (_config.OutputHandler != null) + { + return _config.OutputHandler; + } + + // Default: console output + var consoleHandler = new OutputHandlerConsole(useColors: true, verbose: false); + + // If XML output requested, multiplex console + XML + if (!string.IsNullOrEmpty(_config.XmlOutputPath)) + { + var xmlHandler = new OutputHandlerXml(_config.XmlOutputPath); + return new MultiplexingOutputHandler(consoleHandler, xmlHandler); + } + + return consoleHandler; + } + + /// + /// Main execution flow: Build โ†’ Launch โ†’ Monitor โ†’ Results + /// + public async Task ExecuteAsync() + { + Console.WriteLine($"[Engine] Starting test execution for {_config.KernelProjectPath}"); + Console.WriteLine($"[Engine] Architecture: {_config.Architecture}"); + Console.WriteLine($"[Engine] Timeout: {_config.TimeoutSeconds}s"); + + var stopwatch = Stopwatch.StartNew(); + + // Get suite name from project path + string suiteName = Path.GetFileNameWithoutExtension(_config.KernelProjectPath); + + try + { + // Notify start + _outputHandler.OnTestSuiteStart(suiteName, _config.Architecture); + + // Step 1: Build kernel to ISO + Console.WriteLine("[Engine] Building kernel..."); + string isoPath = await BuildKernelAsync(); + Console.WriteLine($"[Engine] Build complete: {isoPath}"); + + // Step 2: Launch QEMU and monitor execution + Console.WriteLine("[Engine] Launching QEMU..."); + var qemuResult = await LaunchAndMonitorAsync(isoPath); + Console.WriteLine($"[Engine] QEMU execution complete (Exit: {qemuResult.ExitCode}, TimedOut: {qemuResult.TimedOut})"); + + // Step 3: Parse results from UART log + Console.WriteLine("[Engine] Parsing test results..."); + var results = ParseResults(qemuResult); + results.SuiteName = suiteName; + results.TotalDuration = stopwatch.Elapsed; + + Console.WriteLine($"[Engine] Results: {results.PassedTests}/{results.TotalTests} passed"); + + // Notify individual test results + foreach (var test in results.Tests) + { + _outputHandler.OnTestStart(test.TestNumber, test.TestName); + + switch (test.Status) + { + case TestStatus.Passed: + _outputHandler.OnTestPass(test.TestNumber, test.TestName, test.DurationMs); + break; + case TestStatus.Failed: + _outputHandler.OnTestFail(test.TestNumber, test.TestName, test.ErrorMessage, test.DurationMs); + break; + case TestStatus.Skipped: + _outputHandler.OnTestSkip(test.TestNumber, test.TestName, test.ErrorMessage); + break; + } + } + + // Notify end + _outputHandler.OnTestSuiteEnd(results); + _outputHandler.Complete(); + + // Step 4: Cleanup (optional) + if (!_config.KeepBuildArtifacts && !string.IsNullOrEmpty(isoPath)) + { + CleanupBuildArtifacts(isoPath); + } + + return results; + } + catch (Exception ex) + { + Console.WriteLine($"[Engine] ERROR: {ex.Message}"); + _outputHandler.OnError(ex.Message); + + var results = new TestResults + { + SuiteName = suiteName, + Architecture = _config.Architecture, + ErrorMessage = ex.Message, + TotalDuration = stopwatch.Elapsed + }; + + _outputHandler.OnTestSuiteEnd(results); + _outputHandler.Complete(); + + return results; + } + } + + private async Task LaunchAndMonitorAsync(string isoPath) + { + // Setup UART log path + string uartLogPath = _config.UartLogPath; + if (string.IsNullOrEmpty(uartLogPath)) + { + uartLogPath = Path.Combine( + Path.GetDirectoryName(isoPath) ?? ".", + "uart.log" + ); + } + + // Launch QEMU and capture UART + return await _qemuHost.RunKernelAsync(isoPath, uartLogPath, _config.TimeoutSeconds, _config.ShouldShowDisplay); + } + + private TestResults ParseResults(QemuRunResult qemuResult) + { + if (!string.IsNullOrEmpty(qemuResult.ErrorMessage)) + { + return new TestResults + { + Architecture = _config.Architecture, + ErrorMessage = qemuResult.ErrorMessage, + TimedOut = qemuResult.TimedOut, + UartLog = qemuResult.UartLog + }; + } + + // Parse binary protocol messages from UART log + var results = UartMessageParser.ParseUartLog(qemuResult.UartLog, _config.Architecture); + results.TimedOut = qemuResult.TimedOut; + results.UartLog = qemuResult.UartLog; + + return results; + } + + private void CleanupBuildArtifacts(string isoPath) + { + try + { + if (File.Exists(isoPath)) + { + File.Delete(isoPath); + } + + var outputDir = Path.GetDirectoryName(isoPath); + if (!string.IsNullOrEmpty(outputDir) && Directory.Exists(outputDir)) + { + Directory.Delete(outputDir, recursive: true); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Engine] Warning: Failed to cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Cosmos.TestRunner.Engine/Hosts/IQemuHost.cs b/tests/Cosmos.TestRunner.Engine/Hosts/IQemuHost.cs new file mode 100644 index 00000000..8ec2735c --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Hosts/IQemuHost.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace Cosmos.TestRunner.Engine; + +/// +/// Interface for QEMU virtual machine hosts that can run test kernels +/// +public interface IQemuHost +{ + /// + /// Architecture this host targets (x64, ARM64, etc.) + /// + string Architecture { get; } + + /// + /// Run a kernel ISO in QEMU and capture UART output + /// + /// Path to the bootable ISO + /// Path to write UART log output + /// Maximum time to run (default 30s) + /// Show QEMU display window (default false = headless) + /// Exit code and UART log content + Task RunKernelAsync(string isoPath, string uartLogPath, int timeoutSeconds = 30, bool showDisplay = false); +} + +/// +/// Result of running a kernel in QEMU +/// +public record QemuRunResult +{ + public int ExitCode { get; init; } + public string UartLog { get; init; } = string.Empty; + public bool TimedOut { get; init; } + public string ErrorMessage { get; init; } = string.Empty; +} diff --git a/tests/Cosmos.TestRunner.Engine/Hosts/QemuARM64Host.cs b/tests/Cosmos.TestRunner.Engine/Hosts/QemuARM64Host.cs new file mode 100644 index 00000000..32ba0b87 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Hosts/QemuARM64Host.cs @@ -0,0 +1,146 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Cosmos.TestRunner.Engine.Hosts; + +/// +/// QEMU host for ARM64/AArch64 architecture +/// +public class QemuARM64Host : IQemuHost +{ + public string Architecture => "arm64"; + + private readonly string _qemuBinary; + private readonly string _uefiFirmwarePath; + private readonly int _memoryMb; + + public QemuARM64Host( + string qemuBinary = "qemu-system-aarch64", + string uefiFirmwarePath = "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd", + int memoryMb = 512) + { + _qemuBinary = qemuBinary; + _uefiFirmwarePath = uefiFirmwarePath; + _memoryMb = memoryMb; + } + + public async Task RunKernelAsync(string isoPath, string uartLogPath, int timeoutSeconds = 30, bool showDisplay = false) + { + if (!File.Exists(isoPath)) + { + return new QemuRunResult + { + ExitCode = -1, + ErrorMessage = $"ISO file not found: {isoPath}" + }; + } + + if (!File.Exists(_uefiFirmwarePath)) + { + return new QemuRunResult + { + ExitCode = -1, + ErrorMessage = $"UEFI firmware not found: {_uefiFirmwarePath}. Install qemu-efi-aarch64 package." + }; + } + + // Ensure UART log directory exists + var logDir = Path.GetDirectoryName(uartLogPath); + if (!string.IsNullOrEmpty(logDir) && !Directory.Exists(logDir)) + { + Directory.CreateDirectory(logDir); + } + + // Delete existing UART log + if (File.Exists(uartLogPath)) + { + File.Delete(uartLogPath); + } + + // Build QEMU arguments + // Note: Always write UART to file for parsing, display mode only affects GUI + string displayArgs = showDisplay + ? $"-display gtk -vga std -serial file:\"{uartLogPath}\"" + : $"-serial file:\"{uartLogPath}\" -nographic"; + + var startInfo = new ProcessStartInfo + { + FileName = _qemuBinary, + Arguments = $"-M virt -cpu cortex-a72 -m {_memoryMb}M " + + $"-bios \"{_uefiFirmwarePath}\" " + + $"-cdrom \"{isoPath}\" " + + $"-boot d -no-reboot " + + $"{displayArgs}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = false + }; + + using var process = new Process { StartInfo = startInfo }; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + process.Start(); + + // Wait for process to exit or timeout + await process.WaitForExitAsync(cts.Token); + + // Give UART log a moment to flush + await Task.Delay(100); + + // Read UART log + string uartLog = string.Empty; + if (File.Exists(uartLogPath)) + { + uartLog = await File.ReadAllTextAsync(uartLogPath); + } + + return new QemuRunResult + { + ExitCode = process.ExitCode, + UartLog = uartLog, + TimedOut = false + }; + } + catch (OperationCanceledException) + { + // Timeout - kill QEMU + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + + // Give UART log a moment to flush + await Task.Delay(100); + + // Read whatever UART output we got + string uartLog = string.Empty; + if (File.Exists(uartLogPath)) + { + uartLog = await File.ReadAllTextAsync(uartLogPath); + } + + return new QemuRunResult + { + ExitCode = -1, + UartLog = uartLog, + TimedOut = true, + ErrorMessage = $"QEMU timed out after {timeoutSeconds}s" + }; + } + catch (Exception ex) + { + return new QemuRunResult + { + ExitCode = -1, + ErrorMessage = $"Failed to run QEMU: {ex.Message}" + }; + } + } +} diff --git a/tests/Cosmos.TestRunner.Engine/Hosts/QemuX64Host.cs b/tests/Cosmos.TestRunner.Engine/Hosts/QemuX64Host.cs new file mode 100644 index 00000000..72b1850d --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Hosts/QemuX64Host.cs @@ -0,0 +1,128 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Cosmos.TestRunner.Engine.Hosts; + +/// +/// QEMU host for x86-64 architecture +/// +public class QemuX64Host : IQemuHost +{ + public string Architecture => "x64"; + + private readonly string _qemuBinary; + private readonly int _memoryMb; + + public QemuX64Host(string qemuBinary = "qemu-system-x86_64", int memoryMb = 512) + { + _qemuBinary = qemuBinary; + _memoryMb = memoryMb; + } + + public async Task RunKernelAsync(string isoPath, string uartLogPath, int timeoutSeconds = 30, bool showDisplay = false) + { + if (!File.Exists(isoPath)) + { + return new QemuRunResult + { + ExitCode = -1, + ErrorMessage = $"ISO file not found: {isoPath}" + }; + } + + // Ensure UART log directory exists + var logDir = Path.GetDirectoryName(uartLogPath); + if (!string.IsNullOrEmpty(logDir) && !Directory.Exists(logDir)) + { + Directory.CreateDirectory(logDir); + } + + // Delete existing UART log + if (File.Exists(uartLogPath)) + { + File.Delete(uartLogPath); + } + + // Build QEMU arguments + // Note: Always write UART to file for parsing, display mode only affects GUI + string displayArgs = showDisplay + ? $"-display gtk -vga std -serial file:\"{uartLogPath}\"" + : $"-serial file:\"{uartLogPath}\" -nographic"; + + var startInfo = new ProcessStartInfo + { + FileName = _qemuBinary, + Arguments = $"-cdrom \"{isoPath}\" -m {_memoryMb}M -boot d -no-reboot {displayArgs}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = false + }; + + using var process = new Process { StartInfo = startInfo }; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + process.Start(); + + // Wait for process to exit or timeout + await process.WaitForExitAsync(cts.Token); + + // Give UART log a moment to flush + await Task.Delay(100); + + // Read UART log + string uartLog = string.Empty; + if (File.Exists(uartLogPath)) + { + uartLog = await File.ReadAllTextAsync(uartLogPath); + } + + return new QemuRunResult + { + ExitCode = process.ExitCode, + UartLog = uartLog, + TimedOut = false + }; + } + catch (OperationCanceledException) + { + // Timeout - kill QEMU + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + + // Give UART log a moment to flush + await Task.Delay(100); + + // Read whatever UART output we got + string uartLog = string.Empty; + if (File.Exists(uartLogPath)) + { + uartLog = await File.ReadAllTextAsync(uartLogPath); + } + + return new QemuRunResult + { + ExitCode = -1, + UartLog = uartLog, + TimedOut = true, + ErrorMessage = $"QEMU timed out after {timeoutSeconds}s" + }; + } + catch (Exception ex) + { + return new QemuRunResult + { + ExitCode = -1, + ErrorMessage = $"Failed to run QEMU: {ex.Message}" + }; + } + } +} diff --git a/tests/Cosmos.TestRunner.Engine/OutputHandlers/MultiplexingOutputHandler.cs b/tests/Cosmos.TestRunner.Engine/OutputHandlers/MultiplexingOutputHandler.cs new file mode 100644 index 00000000..c2aa5ffa --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/OutputHandlers/MultiplexingOutputHandler.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; + +namespace Cosmos.TestRunner.Engine.OutputHandlers; + +/// +/// Output handler that multiplexes output to multiple handlers simultaneously +/// +public class MultiplexingOutputHandler : OutputHandlerBase +{ + private readonly List _handlers; + + public MultiplexingOutputHandler(params OutputHandlerBase[] handlers) + { + _handlers = new List(handlers ?? Array.Empty()); + } + + public void AddHandler(OutputHandlerBase handler) + { + if (handler == null) + throw new ArgumentNullException(nameof(handler)); + + _handlers.Add(handler); + } + + public override void OnTestSuiteStart(string suiteName, string architecture) + { + foreach (var handler in _handlers) + { + try + { + handler.OnTestSuiteStart(suiteName, architecture); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.OnTestSuiteStart: {ex.Message}"); + } + } + } + + public override void OnTestStart(int testNumber, string testName) + { + foreach (var handler in _handlers) + { + try + { + handler.OnTestStart(testNumber, testName); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.OnTestStart: {ex.Message}"); + } + } + } + + public override void OnTestPass(int testNumber, string testName, uint durationMs) + { + foreach (var handler in _handlers) + { + try + { + handler.OnTestPass(testNumber, testName, durationMs); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.OnTestPass: {ex.Message}"); + } + } + } + + public override void OnTestFail(int testNumber, string testName, string errorMessage, uint durationMs) + { + foreach (var handler in _handlers) + { + try + { + handler.OnTestFail(testNumber, testName, errorMessage, durationMs); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.OnTestFail: {ex.Message}"); + } + } + } + + public override void OnTestSkip(int testNumber, string testName, string reason) + { + foreach (var handler in _handlers) + { + try + { + handler.OnTestSkip(testNumber, testName, reason); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.OnTestSkip: {ex.Message}"); + } + } + } + + public override void OnTestSuiteEnd(TestResults results) + { + foreach (var handler in _handlers) + { + try + { + handler.OnTestSuiteEnd(results); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.OnTestSuiteEnd: {ex.Message}"); + } + } + } + + public override void OnError(string errorMessage) + { + foreach (var handler in _handlers) + { + try + { + handler.OnError(errorMessage); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.OnError: {ex.Message}"); + } + } + } + + public override void Complete() + { + foreach (var handler in _handlers) + { + try + { + handler.Complete(); + } + catch (Exception ex) + { + Console.WriteLine($"[MultiplexingOutputHandler] ERROR in {handler.GetType().Name}.Complete: {ex.Message}"); + } + } + } +} diff --git a/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerBase.cs b/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerBase.cs new file mode 100644 index 00000000..2c1cba4f --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerBase.cs @@ -0,0 +1,65 @@ +using System; + +namespace Cosmos.TestRunner.Engine.OutputHandlers; + +/// +/// Base class for all output handlers - provides abstract interface for structured test result output +/// +public abstract class OutputHandlerBase +{ + /// + /// Called when test execution begins + /// + /// Name of the test suite + /// Target architecture (x64, arm64) + public abstract void OnTestSuiteStart(string suiteName, string architecture); + + /// + /// Called when an individual test starts + /// + /// Sequential test number + /// Name of the test + public abstract void OnTestStart(int testNumber, string testName); + + /// + /// Called when a test passes + /// + /// Sequential test number + /// Name of the test + /// Test duration in milliseconds + public abstract void OnTestPass(int testNumber, string testName, uint durationMs); + + /// + /// Called when a test fails + /// + /// Sequential test number + /// Name of the test + /// Failure message + /// Test duration in milliseconds + public abstract void OnTestFail(int testNumber, string testName, string errorMessage, uint durationMs); + + /// + /// Called when a test is skipped + /// + /// Sequential test number + /// Name of the test + /// Reason for skipping + public abstract void OnTestSkip(int testNumber, string testName, string reason); + + /// + /// Called when test suite execution completes + /// + /// Complete test results + public abstract void OnTestSuiteEnd(TestResults results); + + /// + /// Called when an error occurs outside of test execution + /// + /// Error message + public abstract void OnError(string errorMessage); + + /// + /// Called to flush/finalize output (e.g., write XML file, close streams) + /// + public abstract void Complete(); +} diff --git a/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerConsole.cs b/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerConsole.cs new file mode 100644 index 00000000..ee7ec7e2 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerConsole.cs @@ -0,0 +1,181 @@ +using System; + +namespace Cosmos.TestRunner.Engine.OutputHandlers; + +/// +/// Console output handler with colored, real-time test progress display +/// +public class OutputHandlerConsole : OutputHandlerBase +{ + private readonly bool _useColors; + private readonly bool _verbose; + private string _currentSuite = string.Empty; + private int _passCount = 0; + private int _failCount = 0; + private int _skipCount = 0; + + public OutputHandlerConsole(bool useColors = true, bool verbose = false) + { + _useColors = useColors && !Console.IsOutputRedirected; + _verbose = verbose; + } + + public override void OnTestSuiteStart(string suiteName, string architecture) + { + _currentSuite = suiteName; + _passCount = 0; + _failCount = 0; + _skipCount = 0; + + WriteHeader(); + WriteLine($"Starting test suite: {suiteName}", ConsoleColor.Cyan); + WriteLine($"Architecture: {architecture}", ConsoleColor.Gray); + WriteLine($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}", ConsoleColor.Gray); + WriteHeader(); + } + + public override void OnTestStart(int testNumber, string testName) + { + if (_verbose) + { + Write($"[{testNumber}] {testName} ... ", ConsoleColor.Gray); + } + } + + public override void OnTestPass(int testNumber, string testName, uint durationMs) + { + _passCount++; + + if (_verbose) + { + WriteLine($"PASS ({durationMs}ms)", ConsoleColor.Green); + } + else + { + Write(".", ConsoleColor.Green); + } + } + + public override void OnTestFail(int testNumber, string testName, string errorMessage, uint durationMs) + { + _failCount++; + + if (_verbose) + { + WriteLine($"FAIL ({durationMs}ms)", ConsoleColor.Red); + WriteLine($" Error: {errorMessage}", ConsoleColor.Red); + } + else + { + Write("F", ConsoleColor.Red); + Console.WriteLine(); + WriteLine($"[{testNumber}] {testName}: {errorMessage}", ConsoleColor.Red); + } + } + + public override void OnTestSkip(int testNumber, string testName, string reason) + { + _skipCount++; + + if (_verbose) + { + WriteLine($"SKIP", ConsoleColor.Yellow); + WriteLine($" Reason: {reason}", ConsoleColor.Yellow); + } + else + { + Write("S", ConsoleColor.Yellow); + } + } + + public override void OnTestSuiteEnd(TestResults results) + { + Console.WriteLine(); // Newline after progress indicators + WriteHeader(); + + if (results.TimedOut) + { + WriteLine("TEST SUITE TIMED OUT", ConsoleColor.Red); + WriteHeader(); + } + + if (!string.IsNullOrEmpty(results.ErrorMessage)) + { + WriteLine($"ERROR: {results.ErrorMessage}", ConsoleColor.Red); + WriteHeader(); + } + + // Summary + WriteLine($"Suite: {results.SuiteName}", ConsoleColor.Cyan); + WriteLine($"Total tests: {results.TotalTests}", ConsoleColor.Gray); + WriteLine($"Passed: {results.PassedTests}", results.PassedTests > 0 ? ConsoleColor.Green : ConsoleColor.Gray); + WriteLine($"Failed: {results.FailedTests}", results.FailedTests > 0 ? ConsoleColor.Red : ConsoleColor.Gray); + WriteLine($"Skipped: {results.SkippedTests}", results.SkippedTests > 0 ? ConsoleColor.Yellow : ConsoleColor.Gray); + WriteLine($"Duration: {results.TotalDuration.TotalSeconds:F2}s", ConsoleColor.Gray); + + WriteHeader(); + + // Final result + if (results.AllTestsPassed) + { + WriteLine("ALL TESTS PASSED", ConsoleColor.Green); + } + else if (results.FailedTests > 0) + { + WriteLine($"TESTS FAILED ({results.FailedTests} failures)", ConsoleColor.Red); + } + else if (results.TotalTests == 0) + { + WriteLine("NO TESTS EXECUTED", ConsoleColor.Yellow); + } + + WriteHeader(); + } + + public override void OnError(string errorMessage) + { + Console.WriteLine(); + WriteLine($"ERROR: {errorMessage}", ConsoleColor.Red); + } + + public override void Complete() + { + // Console output is already flushed in real-time + // Nothing to finalize + } + + private void WriteHeader() + { + WriteLine(new string('=', 80), ConsoleColor.DarkGray); + } + + private void Write(string message, ConsoleColor color) + { + if (_useColors) + { + var originalColor = Console.ForegroundColor; + Console.ForegroundColor = color; + Console.Write(message); + Console.ForegroundColor = originalColor; + } + else + { + Console.Write(message); + } + } + + private void WriteLine(string message, ConsoleColor color) + { + if (_useColors) + { + var originalColor = Console.ForegroundColor; + Console.ForegroundColor = color; + Console.WriteLine(message); + Console.ForegroundColor = originalColor; + } + else + { + Console.WriteLine(message); + } + } +} diff --git a/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerXml.cs b/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerXml.cs new file mode 100644 index 00000000..90b7e784 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/OutputHandlers/OutputHandlerXml.cs @@ -0,0 +1,209 @@ +using System; +using System.IO; +using System.Text; +using System.Xml; + +namespace Cosmos.TestRunner.Engine.OutputHandlers; + +/// +/// XML output handler producing JUnit-compatible XML for CI integration +/// +public class OutputHandlerXml : OutputHandlerBase +{ + private readonly string _outputPath; + private readonly StringBuilder _xmlBuilder; + private string _suiteName = string.Empty; + private string _architecture = string.Empty; + private DateTime _suiteStartTime; + + public OutputHandlerXml(string outputPath) + { + _outputPath = outputPath ?? throw new ArgumentNullException(nameof(outputPath)); + _xmlBuilder = new StringBuilder(); + } + + public override void OnTestSuiteStart(string suiteName, string architecture) + { + _suiteName = suiteName; + _architecture = architecture; + _suiteStartTime = DateTime.Now; + + // Clear any previous content + _xmlBuilder.Clear(); + } + + public override void OnTestStart(int testNumber, string testName) + { + // Test starts are tracked but not written to XML directly + } + + public override void OnTestPass(int testNumber, string testName, uint durationMs) + { + // Test passes are recorded in OnTestSuiteEnd + } + + public override void OnTestFail(int testNumber, string testName, string errorMessage, uint durationMs) + { + // Test failures are recorded in OnTestSuiteEnd + } + + public override void OnTestSkip(int testNumber, string testName, string reason) + { + // Skipped tests are recorded in OnTestSuiteEnd + } + + public override void OnTestSuiteEnd(TestResults results) + { + // Generate JUnit XML format + // Note: StringWriter uses UTF-16 internally, which affects the XML declaration + // We fix this in Complete() by replacing utf-16 with utf-8 before writing to file + var settings = new XmlWriterSettings + { + Indent = true, + IndentChars = " ", + Encoding = Encoding.UTF8, + OmitXmlDeclaration = false + }; + + using var stringWriter = new StringWriter(_xmlBuilder); + using var writer = XmlWriter.Create(stringWriter, settings); + + writer.WriteStartDocument(); + + // root element + writer.WriteStartElement("testsuites"); + writer.WriteAttributeString("name", _suiteName); + writer.WriteAttributeString("tests", results.TotalTests.ToString()); + writer.WriteAttributeString("failures", results.FailedTests.ToString()); + writer.WriteAttributeString("skipped", results.SkippedTests.ToString()); + writer.WriteAttributeString("time", results.TotalDuration.TotalSeconds.ToString("F3")); + writer.WriteAttributeString("timestamp", _suiteStartTime.ToString("yyyy-MM-ddTHH:mm:ss")); + + // element + writer.WriteStartElement("testsuite"); + writer.WriteAttributeString("name", _suiteName); + writer.WriteAttributeString("tests", results.TotalTests.ToString()); + writer.WriteAttributeString("failures", results.FailedTests.ToString()); + writer.WriteAttributeString("skipped", results.SkippedTests.ToString()); + writer.WriteAttributeString("time", results.TotalDuration.TotalSeconds.ToString("F3")); + writer.WriteAttributeString("timestamp", _suiteStartTime.ToString("yyyy-MM-ddTHH:mm:ss")); + + // Add architecture as a property + writer.WriteStartElement("properties"); + writer.WriteStartElement("property"); + writer.WriteAttributeString("name", "architecture"); + writer.WriteAttributeString("value", _architecture); + writer.WriteEndElement(); // property + + if (results.TimedOut) + { + writer.WriteStartElement("property"); + writer.WriteAttributeString("name", "timedOut"); + writer.WriteAttributeString("value", "true"); + writer.WriteEndElement(); // property + } + + writer.WriteEndElement(); // properties + + // Individual test cases + foreach (var test in results.Tests) + { + writer.WriteStartElement("testcase"); + writer.WriteAttributeString("name", test.TestName); + writer.WriteAttributeString("classname", _suiteName); + writer.WriteAttributeString("time", (test.DurationMs / 1000.0).ToString("F3")); + + if (test.Status == TestStatus.Failed) + { + writer.WriteStartElement("failure"); + writer.WriteAttributeString("message", test.ErrorMessage); + writer.WriteAttributeString("type", "TestFailure"); + writer.WriteString(test.ErrorMessage); + writer.WriteEndElement(); // failure + } + else if (test.Status == TestStatus.Skipped) + { + writer.WriteStartElement("skipped"); + writer.WriteAttributeString("message", test.ErrorMessage); + writer.WriteEndElement(); // skipped + } + + writer.WriteEndElement(); // testcase + } + + // Suite-level error if any + if (!string.IsNullOrEmpty(results.ErrorMessage)) + { + writer.WriteStartElement("system-err"); + writer.WriteCData(results.ErrorMessage); + writer.WriteEndElement(); // system-err + } + + // UART log as system-out (filter invalid XML characters) + if (!string.IsNullOrEmpty(results.UartLog)) + { + writer.WriteStartElement("system-out"); + writer.WriteCData(FilterInvalidXmlChars(results.UartLog)); + writer.WriteEndElement(); // system-out + } + + writer.WriteEndElement(); // testsuite + writer.WriteEndElement(); // testsuites + + writer.WriteEndDocument(); + } + + public override void OnError(string errorMessage) + { + // Errors are captured in TestResults and written in OnTestSuiteEnd + } + + public override void Complete() + { + // Write XML to file with explicit UTF-8 encoding + try + { + var directory = Path.GetDirectoryName(_outputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // StringWriter uses UTF-16 internally, so XmlWriter writes encoding="utf-16" + // We need to replace it with "utf-8" since we're writing to file as UTF-8 + var xmlContent = _xmlBuilder.ToString().Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + + // Write with UTF-8 encoding (no BOM) to match the XML declaration + File.WriteAllText(_outputPath, xmlContent, new UTF8Encoding(false)); + Console.WriteLine($"[OutputHandlerXml] Wrote test results to: {_outputPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[OutputHandlerXml] ERROR: Failed to write XML: {ex.Message}"); + } + } + + /// + /// Filters characters that are invalid in XML 1.0. + /// Valid characters are: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + /// + private static string FilterInvalidXmlChars(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + var filtered = new StringBuilder(text.Length); + foreach (char c in text) + { + // Allow: tab (0x09), newline (0x0A), carriage return (0x0D), and standard printable characters + if (c == 0x09 || c == 0x0A || c == 0x0D || + (c >= 0x20 && c <= 0xD7FF) || + (c >= 0xE000 && c <= 0xFFFD)) + { + filtered.Append(c); + } + // Skip invalid characters + } + return filtered.ToString(); + } +} diff --git a/tests/Cosmos.TestRunner.Engine/Program.cs b/tests/Cosmos.TestRunner.Engine/Program.cs new file mode 100644 index 00000000..9fecc7f7 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Program.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Cosmos.TestRunner.Engine; + +namespace Cosmos.TestRunner.Engine; + +class Program +{ + static async Task Main(string[] args) + { + Console.WriteLine("Cosmos Test Runner Engine"); + Console.WriteLine("=========================\n"); + + if (args.Length < 1) + { + Console.WriteLine("Usage: Cosmos.TestRunner.Engine [architecture] [timeout] [xml-output] [mode]"); + Console.WriteLine(" kernel-project-path: Path to test kernel project directory"); + Console.WriteLine(" architecture: x64 or arm64 (default: x64)"); + Console.WriteLine(" timeout: Timeout in seconds (default: 30)"); + Console.WriteLine(" xml-output: Optional path for JUnit XML output (use '-' to skip)"); + Console.WriteLine(" mode: ci or dev (default: ci)"); + Console.WriteLine(" ci = headless, automated, fast"); + Console.WriteLine(" dev = visual display, interactive, debugging"); + Console.WriteLine("\nExamples:"); + Console.WriteLine(" Cosmos.TestRunner.Engine tests/Kernels/Cosmos.Kernel.Tests.HelloWorld x64 30"); + Console.WriteLine(" Cosmos.TestRunner.Engine tests/Kernels/Cosmos.Kernel.Tests.HelloWorld x64 30 results.xml"); + Console.WriteLine(" Cosmos.TestRunner.Engine tests/Kernels/Cosmos.Kernel.Tests.HelloWorld x64 60 - dev"); + return 1; + } + + string kernelPath = args[0]; + string architecture = args.Length > 1 ? args[1] : "x64"; + int timeout = args.Length > 2 ? int.Parse(args[2]) : 30; + string xmlOutput = args.Length > 3 && args[3] != "-" ? args[3] : string.Empty; + string modeStr = args.Length > 4 ? args[4] : "ci"; + + TestRunnerMode mode = modeStr.ToLowerInvariant() switch + { + "dev" => TestRunnerMode.Dev, + "ci" => TestRunnerMode.CI, + _ => TestRunnerMode.CI + }; + + // Resolve to absolute path + if (!Path.IsPathRooted(kernelPath)) + { + kernelPath = Path.GetFullPath(kernelPath); + } + + var config = new TestConfiguration + { + KernelProjectPath = kernelPath, + Architecture = architecture, + TimeoutSeconds = timeout, + KeepBuildArtifacts = true, // Keep artifacts for debugging + XmlOutputPath = xmlOutput, + Mode = mode + }; + + Console.WriteLine($"Mode: {mode}"); + if (mode == TestRunnerMode.Dev) + { + Console.WriteLine("โš ๏ธ Dev mode: QEMU display window will open"); + } + + var engine = new Engine(config); + + try + { + var results = await engine.ExecuteAsync(); + + // Output handlers have already displayed results + // Just save UART log and return exit code + + // Save UART log + if (!string.IsNullOrEmpty(results.UartLog)) + { + string uartLogFile = "uart-output.log"; + await File.WriteAllTextAsync(uartLogFile, results.UartLog); + Console.WriteLine($"\nUART log saved to: {uartLogFile}"); + } + + return results.AllTestsPassed ? 0 : 1; + } + catch (Exception ex) + { + Console.WriteLine($"\nโŒ FATAL ERROR: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + return 1; + } + } +} diff --git a/tests/Cosmos.TestRunner.Engine/Protocol/UartMessageParser.cs b/tests/Cosmos.TestRunner.Engine/Protocol/UartMessageParser.cs new file mode 100644 index 00000000..80fe25b2 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/Protocol/UartMessageParser.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Cosmos.TestRunner.Protocol; + +namespace Cosmos.TestRunner.Engine.Protocol; + +/// +/// Parses binary protocol messages from UART log output +/// +public class UartMessageParser +{ + /// + /// Parse UART log and extract test results + /// + public static TestResults ParseUartLog(string uartLog, string architecture) + { + var results = new TestResults { Architecture = architecture }; + + // Extract binary data from UART log (filter out ANSI codes and text) + var binaryData = ExtractBinaryData(uartLog); + + Console.WriteLine($"[UartParser] UART log length: {uartLog.Length} bytes"); + Console.WriteLine($"[UartParser] Binary data length: {binaryData.Length} bytes"); + + // Parse protocol messages + int offset = 0; + int messagesFound = 0; + while (offset < binaryData.Length) + { + int oldOffset = offset; + if (!TryParseMessage(binaryData, ref offset, results)) + { + // Skip byte if we can't parse a valid message + offset++; + } + else if (offset > oldOffset) + { + messagesFound++; + } + } + + Console.WriteLine($"[UartParser] Found {messagesFound} protocol messages"); + Console.WriteLine($"[UartParser] Suite name: {results.SuiteName}"); + Console.WriteLine($"[UartParser] Tests found: {results.Tests.Count}"); + + return results; + } + + private static byte[] ExtractBinaryData(string uartLog) + { + // Convert entire UART log to bytes + // Protocol messages are embedded in the byte stream alongside text output + return Encoding.Latin1.GetBytes(uartLog); + } + + private static bool TryParseMessage(byte[] data, ref int offset, TestResults results) + { + if (offset + 3 > data.Length) + return false; + + byte command = data[offset]; + ushort length = (ushort)(data[offset + 1] | (data[offset + 2] << 8)); + + // Validate we have enough data for payload + if (offset + 3 + length > data.Length) + return false; + + byte[] payload = new byte[length]; + Array.Copy(data, offset + 3, payload, 0, length); + + offset += 3 + length; + + // Parse based on command + switch (command) + { + case Ds2Vs.TestSuiteStart: + ParseTestSuiteStart(payload, results); + return true; + + case Ds2Vs.TestStart: + ParseTestStart(payload, results); + return true; + + case Ds2Vs.TestPass: + ParseTestPass(payload, results); + return true; + + case Ds2Vs.TestFail: + ParseTestFail(payload, results); + return true; + + case Ds2Vs.TestSkip: + ParseTestSkip(payload, results); + return true; + + case Ds2Vs.TestSuiteEnd: + ParseTestSuiteEnd(payload, results); + return true; + + default: + return false; + } + } + + private static void ParseTestSuiteStart(byte[] payload, TestResults results) + { + results.SuiteName = Encoding.UTF8.GetString(payload); + } + + private static void ParseTestStart(byte[] payload, TestResults results) + { + // Payload: [TestNumber:4][TestName:string] + if (payload.Length < 4) return; + + int testNumber = BitConverter.ToInt32(payload, 0); + string testName = Encoding.UTF8.GetString(payload, 4, payload.Length - 4); + + // Add test with pending status + results.Tests.Add(new TestResult + { + TestNumber = testNumber, + TestName = testName, + Status = TestStatus.Passed // Will be updated by Pass/Fail/Skip + }); + } + + private static void ParseTestPass(byte[] payload, TestResults results) + { + // Payload: [TestNumber:4][DurationMs:4] + if (payload.Length < 8) return; + + int testNumber = BitConverter.ToInt32(payload, 0); + uint durationMs = BitConverter.ToUInt32(payload, 4); + + var test = results.Tests.Find(t => t.TestNumber == testNumber); + if (test != null) + { + test.Status = TestStatus.Passed; + test.DurationMs = durationMs; + } + } + + private static void ParseTestFail(byte[] payload, TestResults results) + { + // Payload: [TestNumber:4][ErrorMessage:string] + if (payload.Length < 4) return; + + int testNumber = BitConverter.ToInt32(payload, 0); + string errorMessage = Encoding.UTF8.GetString(payload, 4, payload.Length - 4); + + var test = results.Tests.Find(t => t.TestNumber == testNumber); + if (test != null) + { + test.Status = TestStatus.Failed; + test.ErrorMessage = errorMessage; + } + } + + private static void ParseTestSkip(byte[] payload, TestResults results) + { + // Payload: [TestNumber:4][Reason:string] + if (payload.Length < 4) return; + + int testNumber = BitConverter.ToInt32(payload, 0); + string reason = Encoding.UTF8.GetString(payload, 4, payload.Length - 4); + + var test = results.Tests.Find(t => t.TestNumber == testNumber); + if (test != null) + { + test.Status = TestStatus.Skipped; + test.ErrorMessage = reason; + } + } + + private static void ParseTestSuiteEnd(byte[] payload, TestResults results) + { + // Payload: [TotalTests:4][PassedTests:4][FailedTests:4][SkippedTests:4] + // This is a summary - we already have individual test results + } +} diff --git a/tests/Cosmos.TestRunner.Engine/TestConfiguration.cs b/tests/Cosmos.TestRunner.Engine/TestConfiguration.cs new file mode 100644 index 00000000..da4ee2e3 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/TestConfiguration.cs @@ -0,0 +1,86 @@ +using System; +using Cosmos.TestRunner.Engine.OutputHandlers; + +namespace Cosmos.TestRunner.Engine; + +/// +/// Test runner execution mode +/// +public enum TestRunnerMode +{ + /// + /// CI mode: headless, fast, automated (no display, strict timeouts) + /// + CI, + + /// + /// Dev mode: visual debugging (display window, relaxed timeouts, interactive) + /// + Dev +} + +/// +/// Configuration for a test kernel execution +/// +public class TestConfiguration +{ + /// + /// Path to the test kernel project directory + /// + public string KernelProjectPath { get; set; } = string.Empty; + + /// + /// Target architecture (x64, arm64) + /// + public string Architecture { get; set; } = "x64"; + + /// + /// Build configuration (Debug, Release) + /// + public string BuildConfiguration { get; set; } = "Debug"; + + /// + /// Timeout in seconds for test execution + /// + public int TimeoutSeconds { get; set; } = 30; + + /// + /// Output directory for build artifacts + /// + public string OutputDirectory { get; set; } = string.Empty; + + /// + /// Path to store UART logs + /// + public string UartLogPath { get; set; } = string.Empty; + + /// + /// Whether to keep build artifacts after test + /// + public bool KeepBuildArtifacts { get; set; } = false; + + /// + /// Output handler for test results (defaults to console) + /// + public OutputHandlerBase? OutputHandler { get; set; } + + /// + /// Optional XML output path for JUnit format + /// + public string XmlOutputPath { get; set; } = string.Empty; + + /// + /// Execution mode: Dev (visual, interactive) or CI (headless, automated) + /// + public TestRunnerMode Mode { get; set; } = TestRunnerMode.CI; + + /// + /// Show QEMU display window (overrides Mode if explicitly set) + /// + public bool? ShowDisplay { get; set; } = null; + + /// + /// Computed: Should display be shown (based on Mode and ShowDisplay override) + /// + public bool ShouldShowDisplay => ShowDisplay ?? (Mode == TestRunnerMode.Dev); +} diff --git a/tests/Cosmos.TestRunner.Engine/TestResults.cs b/tests/Cosmos.TestRunner.Engine/TestResults.cs new file mode 100644 index 00000000..a7bb94f5 --- /dev/null +++ b/tests/Cosmos.TestRunner.Engine/TestResults.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Cosmos.TestRunner.Engine; + +/// +/// Results from running a test kernel +/// +public class TestResults +{ + public string SuiteName { get; set; } = string.Empty; + public string Architecture { get; set; } = string.Empty; + public List Tests { get; set; } = new(); + public TimeSpan TotalDuration { get; set; } + public string ErrorMessage { get; set; } = string.Empty; + public bool TimedOut { get; set; } + public string UartLog { get; set; } = string.Empty; + + public int TotalTests => Tests.Count; + public int PassedTests => Tests.Count(t => t.Status == TestStatus.Passed); + public int FailedTests => Tests.Count(t => t.Status == TestStatus.Failed); + public int SkippedTests => Tests.Count(t => t.Status == TestStatus.Skipped); + + public bool AllTestsPassed => Tests.Count > 0 && Tests.All(t => t.Status == TestStatus.Passed); +} + +/// +/// Individual test result +/// +public class TestResult +{ + public int TestNumber { get; set; } + public string TestName { get; set; } = string.Empty; + public TestStatus Status { get; set; } + public uint DurationMs { get; set; } + public string ErrorMessage { get; set; } = string.Empty; +} + +/// +/// Test execution status +/// +public enum TestStatus +{ + Passed, + Failed, + Skipped +} diff --git a/tests/Cosmos.TestRunner.Framework/Assert.cs b/tests/Cosmos.TestRunner.Framework/Assert.cs new file mode 100644 index 00000000..8a39df7a --- /dev/null +++ b/tests/Cosmos.TestRunner.Framework/Assert.cs @@ -0,0 +1,154 @@ +using System; + +namespace Cosmos.TestRunner.Framework +{ + /// + /// Assertion methods for kernel tests + /// + public static class Assert + { + /// + /// Assert that two values are equal + /// + public static void Equal(T expected, T actual) where T : IEquatable + { + if (expected == null && actual == null) + return; + + if (expected == null || actual == null || !expected.Equals(actual)) + { + throw new AssertionException($"Expected {expected}, got {actual}"); + } + } + + /// + /// Assert that two integers are equal + /// + public static void Equal(int expected, int actual) + { + if (expected != actual) + { + throw new AssertionException($"Expected {expected}, got {actual}"); + } + } + + /// + /// Assert that two unsigned integers are equal + /// + public static void Equal(uint expected, uint actual) + { + if (expected != actual) + { + throw new AssertionException($"Expected {expected}, got {actual}"); + } + } + + /// + /// Assert that two longs are equal + /// + public static void Equal(long expected, long actual) + { + if (expected != actual) + { + throw new AssertionException($"Expected {expected}, got {actual}"); + } + } + + /// + /// Assert that two bytes are equal + /// + public static void Equal(byte expected, byte actual) + { + if (expected != actual) + { + throw new AssertionException($"Expected {expected}, got {actual}"); + } + } + + /// + /// Assert that two booleans are equal + /// + public static void Equal(bool expected, bool actual) + { + if (expected != actual) + { + throw new AssertionException($"Expected {expected}, got {actual}"); + } + } + + /// + /// Assert that a value is not null + /// + public static void NotNull(object? obj) + { + if (obj == null) + { + throw new AssertionException("Expected non-null, got null"); + } + } + + /// + /// Assert that a value is null + /// + public static void Null(object? obj) + { + if (obj != null) + { + throw new AssertionException($"Expected null, got {obj}"); + } + } + + /// + /// Assert that a condition is true + /// + public static void True(bool condition, string? message = null) + { + if (!condition) + { + var msg = message != null ? $"Expected true: {message}" : "Expected true, got false"; + throw new AssertionException(msg); + } + } + + /// + /// Assert that a condition is false + /// + public static void False(bool condition, string? message = null) + { + if (condition) + { + var msg = message != null ? $"Expected false: {message}" : "Expected false, got true"; + throw new AssertionException(msg); + } + } + + /// + /// Assert that an action throws a specific exception type + /// + public static void Throws(Action action) where TException : Exception + { + try + { + action(); + throw new AssertionException($"Expected {typeof(TException).Name}, but no exception was thrown"); + } + catch (TException) + { + // Expected exception caught + return; + } + catch (Exception ex) + { + throw new AssertionException($"Expected {typeof(TException).Name}, but got {ex.GetType().Name}"); + } + } + + /// + /// Unconditional failure + /// + public static void Fail(string message) + { + throw new AssertionException(message); + } + } +} diff --git a/tests/Cosmos.TestRunner.Framework/Cosmos.TestRunner.Framework.csproj b/tests/Cosmos.TestRunner.Framework/Cosmos.TestRunner.Framework.csproj new file mode 100644 index 00000000..eb10a5a1 --- /dev/null +++ b/tests/Cosmos.TestRunner.Framework/Cosmos.TestRunner.Framework.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + true + true + Cosmos.TestRunner.Framework + 1.0.0 + NativeAOT-Patcher Contributors + Test framework for NativeAOT kernel tests + + + + + + + diff --git a/tests/Cosmos.TestRunner.Framework/TestRunner.cs b/tests/Cosmos.TestRunner.Framework/TestRunner.cs new file mode 100644 index 00000000..af9d9b75 --- /dev/null +++ b/tests/Cosmos.TestRunner.Framework/TestRunner.cs @@ -0,0 +1,214 @@ +using System; +using System.Diagnostics; +using Cosmos.Kernel.Core.IO; + +namespace Cosmos.TestRunner.Framework +{ + /// + /// Test runner for kernel-side test execution. + /// Sends test results via UART using the binary protocol. + /// + public static class TestRunner + { + private static string? _currentSuite; + private static ushort _testCount; + private static ushort _passedCount; + private static ushort _failedCount; + private static ushort _currentTestNumber; + private static long _testStartTicks; + + /// + /// Start a test suite + /// + public static void Start(string suiteName) + { + _currentSuite = suiteName; + _testCount = 0; + _passedCount = 0; + _failedCount = 0; + _currentTestNumber = 0; + + // Send TestSuiteStart message + SendTestSuiteStart(suiteName); + } + + /// + /// Run a test with automatic exception handling + /// + public static void Run(string testName, Action testAction) + { + _currentTestNumber++; + _testCount++; + + // Send TestStart message + SendTestStart(_currentTestNumber, testName); + + // Record start time + _testStartTicks = Stopwatch.GetTimestamp(); + + try + { + // Execute test + testAction(); + + // Calculate duration + var endTicks = Stopwatch.GetTimestamp(); + var elapsedTicks = endTicks - _testStartTicks; + var durationMs = (uint)((elapsedTicks * 1000) / Stopwatch.Frequency); + + // Test passed + _passedCount++; + SendTestPass(_currentTestNumber, durationMs); + } + catch (AssertionException ex) + { + // Test failed with assertion + _failedCount++; + SendTestFail(_currentTestNumber, ex.Message); + } + catch (Exception ex) + { + // Test failed with unexpected exception + _failedCount++; + SendTestFail(_currentTestNumber, $"Unexpected exception: {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Skip a test + /// + public static void Skip(string testName, string reason) + { + _currentTestNumber++; + _testCount++; + + SendTestStart(_currentTestNumber, testName); + SendTestSkip(_currentTestNumber, reason); + } + + /// + /// Finish the test suite and send summary + /// + public static void Finish() + { + SendTestSuiteEnd(_testCount, _passedCount, _failedCount); + + // Also send a text message for fallback/debugging + Serial.WriteString($"\nTest Suite: {_currentSuite ?? "Unknown"}\n"); + Serial.WriteString($"Total: {_testCount} Passed: {_passedCount} Failed: {_failedCount}\n"); + } + + #region Protocol Message Sending + + // Protocol constants (must match Cosmos.TestRunner.Protocol/Consts.cs) + private const byte TestSuiteStart = 100; + private const byte TestStart = 101; + private const byte TestPass = 102; + private const byte TestFail = 103; + private const byte TestSkip = 104; + private const byte TestSuiteEnd = 105; + + /// + /// Send a protocol message with format: [Command:1][Length:2][Payload:N] + /// + private static void SendMessage(byte command, byte[] payload) + { + // Send command byte + Serial.ComWrite(command); + + // Send length (little-endian ushort) + ushort length = (ushort)payload.Length; + Serial.ComWrite((byte)(length & 0xFF)); + Serial.ComWrite((byte)((length >> 8) & 0xFF)); + + // Send payload + foreach (var b in payload) + { + Serial.ComWrite(b); + } + } + + /// + /// Encode string to UTF-8 bytes (simplified, assumes ASCII for kernel) + /// + private static byte[] EncodeString(string str) + { + var bytes = new byte[str.Length]; + for (int i = 0; i < str.Length; i++) + { + bytes[i] = (byte)str[i]; // ASCII only for simplicity + } + return bytes; + } + + private static void SendTestSuiteStart(string suiteName) + { + var payload = EncodeString(suiteName); + SendMessage(TestSuiteStart, payload); + } + + private static void SendTestStart(ushort testNumber, string testName) + { + var nameBytes = EncodeString(testName); + var payload = new byte[2 + nameBytes.Length]; + payload[0] = (byte)(testNumber & 0xFF); + payload[1] = (byte)((testNumber >> 8) & 0xFF); + Array.Copy(nameBytes, 0, payload, 2, nameBytes.Length); + SendMessage(TestStart, payload); + } + + private static void SendTestPass(ushort testNumber, uint durationMs) + { + var payload = new byte[6]; + payload[0] = (byte)(testNumber & 0xFF); + payload[1] = (byte)((testNumber >> 8) & 0xFF); + payload[2] = (byte)(durationMs & 0xFF); + payload[3] = (byte)((durationMs >> 8) & 0xFF); + payload[4] = (byte)((durationMs >> 16) & 0xFF); + payload[5] = (byte)((durationMs >> 24) & 0xFF); + SendMessage(TestPass, payload); + } + + private static void SendTestFail(ushort testNumber, string errorMessage) + { + var errorBytes = EncodeString(errorMessage); + var payload = new byte[2 + errorBytes.Length]; + payload[0] = (byte)(testNumber & 0xFF); + payload[1] = (byte)((testNumber >> 8) & 0xFF); + Array.Copy(errorBytes, 0, payload, 2, errorBytes.Length); + SendMessage(TestFail, payload); + } + + private static void SendTestSkip(ushort testNumber, string skipReason) + { + var reasonBytes = EncodeString(skipReason); + var payload = new byte[2 + reasonBytes.Length]; + payload[0] = (byte)(testNumber & 0xFF); + payload[1] = (byte)((testNumber >> 8) & 0xFF); + Array.Copy(reasonBytes, 0, payload, 2, reasonBytes.Length); + SendMessage(TestSkip, payload); + } + + private static void SendTestSuiteEnd(ushort total, ushort passed, ushort failed) + { + var payload = new byte[6]; + payload[0] = (byte)(total & 0xFF); + payload[1] = (byte)((total >> 8) & 0xFF); + payload[2] = (byte)(passed & 0xFF); + payload[3] = (byte)((passed >> 8) & 0xFF); + payload[4] = (byte)(failed & 0xFF); + payload[5] = (byte)((failed >> 8) & 0xFF); + SendMessage(TestSuiteEnd, payload); + } + + #endregion + } + + /// + /// Exception thrown when an assertion fails + /// + public class AssertionException : Exception + { + public AssertionException(string message) : base(message) { } + } +} diff --git a/tests/Cosmos.TestRunner.Protocol/Consts.cs b/tests/Cosmos.TestRunner.Protocol/Consts.cs new file mode 100644 index 00000000..be0d4755 --- /dev/null +++ b/tests/Cosmos.TestRunner.Protocol/Consts.cs @@ -0,0 +1,112 @@ +using System; + +namespace Cosmos.TestRunner.Protocol +{ + /// + /// Protocol constants for test runner communication. + /// Ported from CosmosOS Cosmos.Debug.DebugConnectors with test-specific extensions. + /// + public class Consts + { + public const string EngineGUID = "DFE8F1F6-691C-4c08-8FFA-54551AD8FEAF"; + public static uint SerialSignature = 0x19740807; + } + + /// + /// Messages from Guest (Kernel) to Host (Test Runner) + /// Extended with test-specific message types (100-106) + /// + public static class Ds2Vs + { + // Original CosmosOS debug messages (0-25) + public const byte Noop = 0; + public const byte TracePoint = 1; + public const byte Message = 192; + public const byte BreakPoint = 3; + public const byte Error = 4; + public const byte Pointer = 5; + public const byte Started = 6; + public const byte MethodContext = 7; + public const byte MemoryData = 8; + public const byte CmdCompleted = 9; + public const byte Registers = 10; + public const byte Frame = 11; + public const byte Stack = 12; + public const byte Pong = 13; + public const byte BreakPointAsm = 14; + public const byte StackCorruptionOccurred = 15; + public const byte MessageBox = 16; + public const byte NullReferenceOccurred = 17; + public const byte SimpleNumber = 18; + public const byte SimpleLongNumber = 19; + public const byte ComplexNumber = 20; + public const byte ComplexLongNumber = 21; + public const byte StackOverflowOccurred = 22; + public const byte InterruptOccurred = 23; + public const byte CoreDump = 24; + public const byte KernelPanic = 25; + + // Test runner specific messages (100-106) + /// + /// Sent when test suite starts. Payload: string (suite name) + /// + public const byte TestSuiteStart = 100; + + /// + /// Sent when individual test starts. Payload: ushort (test number) + string (test name) + /// + public const byte TestStart = 101; + + /// + /// Sent when test passes. Payload: ushort (test number) + uint (duration in milliseconds) + /// + public const byte TestPass = 102; + + /// + /// Sent when test fails. Payload: ushort (test number) + string (error message) + /// + public const byte TestFail = 103; + + /// + /// Sent when test is skipped. Payload: ushort (test number) + string (skip reason) + /// + public const byte TestSkip = 104; + + /// + /// Sent when test suite ends. Payload: ushort (total) + ushort (passed) + ushort (failed) + /// + public const byte TestSuiteEnd = 105; + + /// + /// Sent on kernel startup to identify architecture. Payload: byte (arch_id) + byte (cpu_count) + /// Architecture IDs: 1=x86, 2=x64, 3=ARM32, 4=ARM64 + /// + public const byte ArchitectureInfo = 106; + } + + /// + /// Messages from Host (Test Runner) to Guest (Kernel) + /// For test runner, we primarily receive messages, so this is minimal. + /// + public static class Vs2Ds + { + public const byte Noop = 0; + public const byte Continue = 4; + public const byte Ping = 17; + + // Make sure this is always the last entry + public const byte Max = 21; + } + + /// + /// Architecture identifiers for ArchitectureInfo message + /// + public enum Architecture : byte + { + Unknown = 0, + x86 = 1, + x64 = 2, + ARM32 = 3, + ARM64 = 4 + } +} diff --git a/tests/Cosmos.TestRunner.Protocol/Cosmos.TestRunner.Protocol.csproj b/tests/Cosmos.TestRunner.Protocol/Cosmos.TestRunner.Protocol.csproj new file mode 100644 index 00000000..b80d8f5e --- /dev/null +++ b/tests/Cosmos.TestRunner.Protocol/Cosmos.TestRunner.Protocol.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + true + + + diff --git a/tests/Cosmos.TestRunner.Protocol/Messages.cs b/tests/Cosmos.TestRunner.Protocol/Messages.cs new file mode 100644 index 00000000..db2ba181 --- /dev/null +++ b/tests/Cosmos.TestRunner.Protocol/Messages.cs @@ -0,0 +1,304 @@ +using System; +using System.Text; + +namespace Cosmos.TestRunner.Protocol +{ + /// + /// Base class for all protocol messages + /// Message format: [Command:1][Length:2][Payload:N] + /// + public abstract class ProtocolMessage + { + public abstract byte Command { get; } + public abstract byte[] GetPayload(); + + /// + /// Serialize message to byte array + /// + public byte[] Serialize() + { + var payload = GetPayload(); + var length = (ushort)payload.Length; + + var result = new byte[3 + payload.Length]; + result[0] = Command; + result[1] = (byte)(length & 0xFF); // Little-endian + result[2] = (byte)((length >> 8) & 0xFF); + Array.Copy(payload, 0, result, 3, payload.Length); + + return result; + } + } + + /// + /// Test suite started + /// + public class TestSuiteStartMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.TestSuiteStart; + public string SuiteName { get; set; } = ""; + + public TestSuiteStartMessage() { } + public TestSuiteStartMessage(string suiteName) + { + SuiteName = suiteName; + } + + public override byte[] GetPayload() + { + return Encoding.UTF8.GetBytes(SuiteName); + } + + public static TestSuiteStartMessage Deserialize(byte[] payload) + { + return new TestSuiteStartMessage(Encoding.UTF8.GetString(payload)); + } + } + + /// + /// Individual test started + /// + public class TestStartMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.TestStart; + public ushort TestNumber { get; set; } + public string TestName { get; set; } = ""; + + public TestStartMessage() { } + public TestStartMessage(ushort testNumber, string testName) + { + TestNumber = testNumber; + TestName = testName; + } + + public override byte[] GetPayload() + { + var nameBytes = Encoding.UTF8.GetBytes(TestName); + var result = new byte[2 + nameBytes.Length]; + result[0] = (byte)(TestNumber & 0xFF); + result[1] = (byte)((TestNumber >> 8) & 0xFF); + Array.Copy(nameBytes, 0, result, 2, nameBytes.Length); + return result; + } + + public static TestStartMessage Deserialize(byte[] payload) + { + if (payload.Length < 2) + throw new ArgumentException("Invalid TestStart payload length"); + + var testNumber = (ushort)(payload[0] | (payload[1] << 8)); + var testName = Encoding.UTF8.GetString(payload, 2, payload.Length - 2); + return new TestStartMessage(testNumber, testName); + } + } + + /// + /// Test passed + /// + public class TestPassMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.TestPass; + public ushort TestNumber { get; set; } + public uint DurationMs { get; set; } + + public TestPassMessage() { } + public TestPassMessage(ushort testNumber, uint durationMs) + { + TestNumber = testNumber; + DurationMs = durationMs; + } + + public override byte[] GetPayload() + { + var result = new byte[6]; + result[0] = (byte)(TestNumber & 0xFF); + result[1] = (byte)((TestNumber >> 8) & 0xFF); + result[2] = (byte)(DurationMs & 0xFF); + result[3] = (byte)((DurationMs >> 8) & 0xFF); + result[4] = (byte)((DurationMs >> 16) & 0xFF); + result[5] = (byte)((DurationMs >> 24) & 0xFF); + return result; + } + + public static TestPassMessage Deserialize(byte[] payload) + { + if (payload.Length != 6) + throw new ArgumentException("Invalid TestPass payload length"); + + var testNumber = (ushort)(payload[0] | (payload[1] << 8)); + var duration = (uint)(payload[2] | (payload[3] << 8) | (payload[4] << 16) | (payload[5] << 24)); + return new TestPassMessage(testNumber, duration); + } + } + + /// + /// Test failed + /// + public class TestFailMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.TestFail; + public ushort TestNumber { get; set; } + public string ErrorMessage { get; set; } = ""; + + public TestFailMessage() { } + public TestFailMessage(ushort testNumber, string errorMessage) + { + TestNumber = testNumber; + ErrorMessage = errorMessage; + } + + public override byte[] GetPayload() + { + var errorBytes = Encoding.UTF8.GetBytes(ErrorMessage); + var result = new byte[2 + errorBytes.Length]; + result[0] = (byte)(TestNumber & 0xFF); + result[1] = (byte)((TestNumber >> 8) & 0xFF); + Array.Copy(errorBytes, 0, result, 2, errorBytes.Length); + return result; + } + + public static TestFailMessage Deserialize(byte[] payload) + { + if (payload.Length < 2) + throw new ArgumentException("Invalid TestFail payload length"); + + var testNumber = (ushort)(payload[0] | (payload[1] << 8)); + var errorMessage = Encoding.UTF8.GetString(payload, 2, payload.Length - 2); + return new TestFailMessage(testNumber, errorMessage); + } + } + + /// + /// Test skipped + /// + public class TestSkipMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.TestSkip; + public ushort TestNumber { get; set; } + public string SkipReason { get; set; } = ""; + + public TestSkipMessage() { } + public TestSkipMessage(ushort testNumber, string skipReason) + { + TestNumber = testNumber; + SkipReason = skipReason; + } + + public override byte[] GetPayload() + { + var reasonBytes = Encoding.UTF8.GetBytes(SkipReason); + var result = new byte[2 + reasonBytes.Length]; + result[0] = (byte)(TestNumber & 0xFF); + result[1] = (byte)((TestNumber >> 8) & 0xFF); + Array.Copy(reasonBytes, 0, result, 2, reasonBytes.Length); + return result; + } + + public static TestSkipMessage Deserialize(byte[] payload) + { + if (payload.Length < 2) + throw new ArgumentException("Invalid TestSkip payload length"); + + var testNumber = (ushort)(payload[0] | (payload[1] << 8)); + var skipReason = Encoding.UTF8.GetString(payload, 2, payload.Length - 2); + return new TestSkipMessage(testNumber, skipReason); + } + } + + /// + /// Test suite ended + /// + public class TestSuiteEndMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.TestSuiteEnd; + public ushort TotalTests { get; set; } + public ushort PassedTests { get; set; } + public ushort FailedTests { get; set; } + + public TestSuiteEndMessage() { } + public TestSuiteEndMessage(ushort total, ushort passed, ushort failed) + { + TotalTests = total; + PassedTests = passed; + FailedTests = failed; + } + + public override byte[] GetPayload() + { + var result = new byte[6]; + result[0] = (byte)(TotalTests & 0xFF); + result[1] = (byte)((TotalTests >> 8) & 0xFF); + result[2] = (byte)(PassedTests & 0xFF); + result[3] = (byte)((PassedTests >> 8) & 0xFF); + result[4] = (byte)(FailedTests & 0xFF); + result[5] = (byte)((FailedTests >> 8) & 0xFF); + return result; + } + + public static TestSuiteEndMessage Deserialize(byte[] payload) + { + if (payload.Length != 6) + throw new ArgumentException("Invalid TestSuiteEnd payload length"); + + var total = (ushort)(payload[0] | (payload[1] << 8)); + var passed = (ushort)(payload[2] | (payload[3] << 8)); + var failed = (ushort)(payload[4] | (payload[5] << 8)); + return new TestSuiteEndMessage(total, passed, failed); + } + } + + /// + /// Architecture information + /// + public class ArchitectureInfoMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.ArchitectureInfo; + public Architecture ArchitectureId { get; set; } + public byte CpuCount { get; set; } + + public ArchitectureInfoMessage() { } + public ArchitectureInfoMessage(Architecture arch, byte cpuCount) + { + ArchitectureId = arch; + CpuCount = cpuCount; + } + + public override byte[] GetPayload() + { + return new byte[] { (byte)ArchitectureId, CpuCount }; + } + + public static ArchitectureInfoMessage Deserialize(byte[] payload) + { + if (payload.Length != 2) + throw new ArgumentException("Invalid ArchitectureInfo payload length"); + + return new ArchitectureInfoMessage((Architecture)payload[0], payload[1]); + } + } + + /// + /// Simple text message (from original CosmosOS protocol) + /// + public class TextMessage : ProtocolMessage + { + public override byte Command => Ds2Vs.Message; + public string Text { get; set; } = ""; + + public TextMessage() { } + public TextMessage(string text) + { + Text = text; + } + + public override byte[] GetPayload() + { + return Encoding.UTF8.GetBytes(Text); + } + + public static TextMessage Deserialize(byte[] payload) + { + return new TextMessage(Encoding.UTF8.GetString(payload)); + } + } +} diff --git a/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Bootloader/limine.conf b/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Bootloader/limine.conf new file mode 100644 index 00000000..2462ce04 --- /dev/null +++ b/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Bootloader/limine.conf @@ -0,0 +1,10 @@ +# Timeout in seconds that Limine will use before automatically booting. +timeout: 0 + +# The entry name that will be displayed in the boot menu. +/Limine Template + # We use the Limine boot protocol. + protocol: limine + + # Path to the kernel to boot. boot():/ represents the partition on which limine.conf is located. + path: boot():/boot/Cosmos.Kernel.Tests.HelloWorld.elf \ No newline at end of file diff --git a/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Cosmos.Kernel.Tests.HelloWorld.csproj b/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Cosmos.Kernel.Tests.HelloWorld.csproj new file mode 100644 index 00000000..8103b356 --- /dev/null +++ b/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Cosmos.Kernel.Tests.HelloWorld.csproj @@ -0,0 +1,69 @@ + + + + + + Exe + net9.0 + true + System.Private.CoreLib + + + false + enable + enable + true + true + true + + + false + false + + + + + + + + + + -O2 -fno-stack-protector -nostdinc -isystem /usr/lib/gcc/x86_64-linux-gnu/13/include -fno-builtin -m64 -mcmodel=kernel -fno-PIC -ffreestanding + + + + + -O2 -fno-stack-protector -nostdinc -isystem /usr/lib/gcc/aarch64-linux-gnu/13/include -fno-builtin -ffreestanding + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Kernel.cs b/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Kernel.cs new file mode 100644 index 00000000..d67ef3c7 --- /dev/null +++ b/tests/Kernels/Cosmos.Kernel.Tests.HelloWorld/Kernel.cs @@ -0,0 +1,67 @@ +using System; +using System.Runtime.InteropServices; +using Cosmos.Kernel.Core.IO; +using Cosmos.TestRunner.Framework; +using static Cosmos.TestRunner.Framework.TestRunner; +using static Cosmos.TestRunner.Framework.Assert; + +namespace Cosmos.Kernel.Tests.HelloWorld +{ + internal unsafe static partial class Program + { + /// + /// Unmanaged entry point called by the bootloader + /// + [UnmanagedCallersOnly(EntryPoint = "__managed__Main")] + private static void KernelMain() => Main(); + + /// + /// Main test kernel entry point + /// + private static void Main() + { + // Test that we can reach Main() at all + Serial.WriteString("[HelloWorld] Main() reached!\n"); + Serial.WriteString("[HelloWorld] Starting tests...\n"); + + // Initialize test suite + Start("HelloWorld Basic Tests"); + + // Test 1: Basic arithmetic + Run("Test_BasicArithmetic", () => + { + int result = 2 + 2; + Equal(4, result); + }); + + // Test 2: Boolean logic + Run("Test_BooleanLogic", () => + { + bool isTrue = true; + True(isTrue); + False(!isTrue); + }); + + // Test 3: Integer comparison + Run("Test_IntegerComparison", () => + { + int a = 10; + int b = 10; + int c = 20; + + Equal(a, b); + True(a < c); + False(a > c); + }); + + // Finish test suite + Finish(); + + // Output completion message + Serial.WriteString("\n[Tests Complete - System Halting]\n"); + + // Halt the system with infinite loop + while (true) ; + } + } +} diff --git a/tests/Kernels/Cosmos.Kernel.Tests.Memory/Bootloader/limine.conf b/tests/Kernels/Cosmos.Kernel.Tests.Memory/Bootloader/limine.conf new file mode 100644 index 00000000..2462ce04 --- /dev/null +++ b/tests/Kernels/Cosmos.Kernel.Tests.Memory/Bootloader/limine.conf @@ -0,0 +1,10 @@ +# Timeout in seconds that Limine will use before automatically booting. +timeout: 0 + +# The entry name that will be displayed in the boot menu. +/Limine Template + # We use the Limine boot protocol. + protocol: limine + + # Path to the kernel to boot. boot():/ represents the partition on which limine.conf is located. + path: boot():/boot/Cosmos.Kernel.Tests.HelloWorld.elf \ No newline at end of file diff --git a/tests/Kernels/Cosmos.Kernel.Tests.Memory/Cosmos.Kernel.Tests.Memory.csproj b/tests/Kernels/Cosmos.Kernel.Tests.Memory/Cosmos.Kernel.Tests.Memory.csproj new file mode 100644 index 00000000..8103b356 --- /dev/null +++ b/tests/Kernels/Cosmos.Kernel.Tests.Memory/Cosmos.Kernel.Tests.Memory.csproj @@ -0,0 +1,69 @@ + + + + + + Exe + net9.0 + true + System.Private.CoreLib + + + false + enable + enable + true + true + true + + + false + false + + + + + + + + + + -O2 -fno-stack-protector -nostdinc -isystem /usr/lib/gcc/x86_64-linux-gnu/13/include -fno-builtin -m64 -mcmodel=kernel -fno-PIC -ffreestanding + + + + + -O2 -fno-stack-protector -nostdinc -isystem /usr/lib/gcc/aarch64-linux-gnu/13/include -fno-builtin -ffreestanding + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Kernels/Cosmos.Kernel.Tests.Memory/Kernel.cs b/tests/Kernels/Cosmos.Kernel.Tests.Memory/Kernel.cs new file mode 100644 index 00000000..b5db01d4 --- /dev/null +++ b/tests/Kernels/Cosmos.Kernel.Tests.Memory/Kernel.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; +using Cosmos.Kernel.Core.IO; +using Cosmos.TestRunner.Framework; + +namespace Cosmos.Kernel.Tests.Memory +{ + internal unsafe static partial class Program + { + [UnmanagedCallersOnly(EntryPoint = "__managed__Main")] + private static void KernelMain() => Main(); + + private static void Main() + { + Serial.WriteString("[Memory Tests] Starting test suite\n"); + TestRunner.Start(); + + // Boxing/Unboxing Tests + TestBoxingChar(); + TestBoxingInt32(); + TestBoxingByte(); + TestBoxingLong(); + TestBoxingNullable(); + TestBoxingInterface(); + TestBoxingCustomStruct(); + TestArrayCopyWithBoxing(); + + // Memory Allocation Tests + TestCharArrayAllocation(); + TestStringAllocation(); + TestIntArrayAllocation(); + TestStringConcatenation(); + TestStringBuilder(); + + // Generic Collection Tests + TestListInt(); + TestListString(); + TestListByte(); + TestListLong(); + TestListStruct(); + TestListContains(); + TestListIndexOf(); + TestListRemoveAt(); + + Serial.WriteString("[Memory Tests] All tests completed\n"); + TestRunner.Finish(); + } + + // ==================== Boxing/Unboxing Tests ==================== + + private static void TestBoxingChar() + { + object boxed = 'c'; + Assert.Equal("c", boxed.ToString(), "Char.ToString on boxed char should work"); + Assert.Equal(0x00630063, boxed.GetHashCode(), "Char.GetHashCode on boxed char should work"); + + char unboxed = (char)boxed; + TestRunner.Run("Boxing: char to object and back", unboxed == 'c'); + } + + private static void TestBoxingInt32() + { + object boxed = 42; + Assert.Equal("42", boxed.ToString(), "Int32.ToString on boxed int"); + Assert.Equal(42, boxed.GetHashCode(), "Int32.GetHashCode on boxed int"); + Assert.True(boxed.Equals(42), "Int32.Equals on boxed int (same value)"); + Assert.True(!boxed.Equals(5), "Int32.Equals on boxed int (different value)"); + + object boxed2 = 42; + Assert.True(Object.Equals(boxed, boxed2), "Object.Equals with two boxed ints"); + + int unboxed = (int)boxed; + TestRunner.Run("Boxing: int to object and back", unboxed == 42); + } + + private static void TestBoxingByte() + { + byte value = 255; + object boxed = value; + byte unboxed = (byte)boxed; + TestRunner.Run("Boxing: byte to object and back", unboxed == 255); + } + + private static void TestBoxingLong() + { + long value = 9876543210L; + object boxed = value; + long unboxed = (long)boxed; + TestRunner.Run("Boxing: long to object and back", unboxed == 9876543210L); + } + + private static void TestBoxingNullable() + { + // Test null case + int? nullableNull = null; + object boxedNull = nullableNull; + TestRunner.Run("Boxing: Nullable null boxes to null", boxedNull == null); + + // Test value case + int? nullableValue = 777; + object boxedValue = nullableValue; + TestRunner.Run("Boxing: Nullable with value boxes correctly", + boxedValue != null && (int)boxedValue == 777); + } + + private static void TestBoxingInterface() + { + int value = 100; + IComparable comparable = value; + TestRunner.Run("Boxing: int to interface (IComparable)", comparable != null); + } + + private static void TestBoxingCustomStruct() + { + TestPoint point = new TestPoint { X = 10, Y = 20 }; + object boxed = point; + TestPoint unboxed = (TestPoint)boxed; + TestRunner.Run("Boxing: custom struct box/unbox", unboxed.X == 10 && unboxed.Y == 20); + } + + private static void TestArrayCopyWithBoxing() + { + int[] sourceIntArray = new int[] { 10, 20, 30 }; + object[] destObjectArray = new object[3]; + Array.Copy(sourceIntArray, destObjectArray, 3); + + bool passed = (int)destObjectArray[0] == 10 && + (int)destObjectArray[1] == 20 && + (int)destObjectArray[2] == 30; + TestRunner.Run("Boxing: Array.Copy with automatic boxing", passed); + } + + // ==================== Memory Allocation Tests ==================== + + private static void TestCharArrayAllocation() + { + char[] testChars = new char[] { 'R', 'h', 'p' }; + TestRunner.Run("Memory: char array allocation", testChars.Length == 3 && testChars[0] == 'R'); + } + + private static void TestStringAllocation() + { + char[] chars = new char[] { 'R', 'h', 'p' }; + string str = new string(chars); + TestRunner.Run("Memory: string allocation from char array", str == "Rhp"); + } + + private static void TestIntArrayAllocation() + { + int[] array = new int[100]; + for (int i = 0; i < 10; i++) + { + array[i] = i * 10; + } + TestRunner.Run("Memory: int array allocation and access", + array[0] == 0 && array[1] == 10 && array[2] == 20); + } + + private static void TestStringConcatenation() + { + string str1 = "Hello"; + string str2 = "World"; + string str3 = str1 + " " + str2; + TestRunner.Run("Memory: string concatenation", str3 == "Hello World"); + } + + private static void TestStringBuilder() + { + StringBuilder sb = new StringBuilder(); + sb.Append("Hello"); + sb.Append(" "); + sb.Append("StringBuilder"); + string result = sb.ToString(); + TestRunner.Run("Memory: StringBuilder operations", result == "Hello StringBuilder"); + } + + // ==================== Generic Collection Tests ==================== + + private static void TestListInt() + { + List list = new List(); + list.Add(100); + list.Add(200); + list.Add(300); + TestRunner.Run("Collections: List Add and indexer", + list.Count == 3 && list[0] == 100 && list[1] == 200 && list[2] == 300); + } + + private static void TestListString() + { + List list = new List(); + list.Add("First"); + list.Add("Second"); + list.Add("Third"); + list.Add("Fourth"); + list.Add("Fifth"); + + TestRunner.Run("Collections: List with resize", + list.Count == 5 && list[0] == "First" && list[4] == "Fifth"); + } + + private static void TestListByte() + { + List list = new List(); + list.Add(0xFF); + list.Add(0x00); + list.Add(0xAB); + list.Add(0x12); + + TestRunner.Run("Collections: List operations", + list.Count == 4 && list[0] == 0xFF && list[2] == 0xAB); + } + + private static void TestListLong() + { + List list = new List(); + list.Add(0x123456789ABCDEF0); + list.Add(-9999999999999); + list.Add(42); + + TestRunner.Run("Collections: List with 64-bit values", + list.Count == 3 && list[0] == 0x123456789ABCDEF0 && list[2] == 42); + } + + private static void TestListStruct() + { + List list = new List(); + list.Add(new TestPoint { X = 1, Y = 2 }); + list.Add(new TestPoint { X = 3, Y = 4 }); + list.Add(new TestPoint { X = 5, Y = 6 }); + + TestRunner.Run("Collections: List operations", + list.Count == 3 && list[0].X == 1 && list[2].Y == 6); + } + + private static void TestListContains() + { + List list = new List(); + list.Add(10); + list.Add(20); + list.Add(30); + + bool found20 = list.Contains(20); + bool found99 = list.Contains(99); + + TestRunner.Run("Collections: List.Contains method", found20 && !found99); + } + + private static void TestListIndexOf() + { + List list = new List(); + list.Add(10); + list.Add(20); + list.Add(30); + + int index20 = list.IndexOf(20); + int index99 = list.IndexOf(99); + + TestRunner.Run("Collections: List.IndexOf method", index20 == 1 && index99 == -1); + } + + private static void TestListRemoveAt() + { + List list = new List(); + list.Add(10); + list.Add(20); + list.Add(30); + list.Add(40); + list.Add(50); + + int idx = list.IndexOf(30); + list.RemoveAt(idx); + + TestRunner.Run("Collections: List.RemoveAt method", + list.Count == 4 && list[2] == 40); + } + } + + // Test struct for boxing and collection tests + internal struct TestPoint + { + public int X; + public int Y; + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..9d46d2e3 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,248 @@ +# Cosmos Test Runner + +NativeAOT-compatible test runner for Cosmos OS kernels with QEMU execution and CI integration. + +## Quick Start + +### Run Tests from VS Code + +**Using Tasks (Recommended):** +1. Press `Ctrl+Shift+P` and type "Tasks: Run Task" +2. Select one of: + - **Run Test: HelloWorld (x64)** - Run with console + XML output + - **Run Test: HelloWorld (x64, Console Only)** - Console output only + - **Run Test: HelloWorld (ARM64)** - ARM64 test with XML output + +**Using Test Menu:** +- Press `Ctrl+Shift+P` โ†’ "Tasks: Run Test Task" +- Default test task: HelloWorld (x64) + +**Debug Test Runner:** +1. Go to Run & Debug panel (`Ctrl+Shift+D`) +2. Select configuration: + - **Debug Test Runner (HelloWorld x64)** + - **Debug Test Runner (HelloWorld ARM64)** +3. Press `F5` to start debugging + +### Run Tests from Command Line + +```bash +# Run test with XML output +dotnet run --project tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj -- \ + tests/Kernels/Cosmos.Kernel.Tests.HelloWorld \ + x64 \ + 60 \ + test-results.xml + +# Run test with console only +dotnet run --project tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj -- \ + tests/Kernels/Cosmos.Kernel.Tests.HelloWorld \ + x64 \ + 60 +``` + +**Arguments:** +1. Kernel project path (absolute or relative) +2. Architecture: `x64` or `arm64` +3. Timeout in seconds (e.g., `60`) +4. (Optional) XML output path for JUnit format + +## Output Formats + +### Console Output (Colored) +``` +================================================================================ +Starting test suite: HelloWorld Basic Tests +Architecture: x64 +Time: 2025-11-05 04:06:48 +================================================================================ +.F.S.. +[2] Test_StringEquality: Assertion failed: Expected "Hello" but got "World" +================================================================================ +Suite: HelloWorld Basic Tests +Total tests: 6 +Passed: 4 +Failed: 1 +Skipped: 1 +Duration: 2.45s +================================================================================ +TESTS FAILED (1 failures) +================================================================================ +``` + +### XML Output (JUnit Format) +```xml + + + + + + + + + Expected "Hello" but got "World" + + + + +``` + +## Project Structure + +``` +tests/ +โ”œโ”€โ”€ Cosmos.TestRunner.Engine/ # Test runner execution engine +โ”‚ โ”œโ”€โ”€ Engine.cs # Main orchestration +โ”‚ โ”œโ”€โ”€ Engine.Build.cs # NativeAOT build pipeline +โ”‚ โ”œโ”€โ”€ Hosts/ # QEMU host implementations +โ”‚ โ”‚ โ”œโ”€โ”€ QemuX64Host.cs +โ”‚ โ”‚ โ””โ”€โ”€ QemuARM64Host.cs +โ”‚ โ”œโ”€โ”€ OutputHandlers/ # Result output formats +โ”‚ โ”‚ โ”œโ”€โ”€ OutputHandlerConsole.cs +โ”‚ โ”‚ โ””โ”€โ”€ OutputHandlerXml.cs +โ”‚ โ””โ”€โ”€ Protocol/ # UART protocol parser +โ”œโ”€โ”€ Cosmos.TestRunner.Framework/ # In-kernel test framework +โ”‚ โ”œโ”€โ”€ TestRunner.cs # Test execution (Start/Run/Finish) +โ”‚ โ””โ”€โ”€ Assert.cs # Assertion methods +โ”œโ”€โ”€ Cosmos.TestRunner.Protocol/ # Protocol definitions +โ”‚ โ””โ”€โ”€ Messages.cs # Binary protocol constants +โ””โ”€โ”€ Kernels/ # Test kernel projects + โ””โ”€โ”€ Cosmos.Kernel.Tests.HelloWorld/ +``` + +## Writing Test Kernels + +### Basic Test Kernel + +```csharp +using Cosmos.TestRunner.Framework; +using static Cosmos.TestRunner.Framework.TestRunner; +using static Cosmos.TestRunner.Framework.Assert; + +internal unsafe static partial class Program +{ + [UnmanagedCallersOnly(EntryPoint = "__managed__Main")] + private static void KernelMain() => Main(); + + private static void Main() + { + // Initialize test suite + Start("My Test Suite"); + + // Run tests + Run("Test_Addition", () => + { + int result = 2 + 2; + Equal(4, result); + }); + + Run("Test_StringOps", () => + { + string str = "Hello"; + Equal("Hello", str); + NotNull(str); + }); + + // Finish and send results + Finish(); + + while (true) ; // Halt + } +} +``` + +### Available Assertions + +```csharp +// Equality +Assert.Equal(expected, actual); +Assert.Equal(expected, actual); // Generic + +// Null checks +Assert.Null(obj); +Assert.NotNull(obj); + +// Boolean +Assert.True(condition); +Assert.False(condition); + +// Exception handling +Assert.Throws(() => { /* code */ }); + +// Manual failure +Assert.Fail("Custom error message"); +``` + +## CI Integration + +### GitHub Actions + +```yaml +- name: Run Cosmos Tests + run: | + dotnet run --project tests/Cosmos.TestRunner.Engine/Cosmos.TestRunner.Engine.csproj -- \ + tests/Kernels/Cosmos.Kernel.Tests.HelloWorld \ + x64 \ + 120 \ + test-results.xml + +- name: Publish Test Results + uses: dorny/test-reporter@v1 + if: always() + with: + name: Cosmos Tests + path: test-results.xml + reporter: java-junit +``` + +## Exit Codes + +- **0**: All tests passed +- **1**: Tests failed or execution error +- **137**: Timeout (SIGKILL) + +## UART Log + +The test runner captures full UART output to `uart-output.log` for debugging: + +```bash +# View UART log +cat uart-output.log + +# Search for specific output +grep "ERROR" uart-output.log +``` + +## Troubleshooting + +### Timeout Issues +- Increase timeout value (3rd argument) +- Check UART log for kernel boot issues +- Verify QEMU is installed: `qemu-system-x86_64 --version` + +### Build Failures +- Run `.devcontainer/postCreateCommand.sh` to rebuild framework +- Check that NuGet packages are restored +- Verify .NET 9 SDK is installed + +### ARM64 Issues +- Ensure UEFI firmware is installed: `/usr/share/qemu-efi-aarch64/QEMU_EFI.fd` +- Install on Ubuntu: `sudo apt install qemu-efi-aarch64` +- Use longer timeout (90s+) for ARM64 + +## Architecture + +The test runner uses a binary protocol over UART serial: + +1. **Kernel Side**: `TestRunner.Framework` sends binary messages +2. **UART Capture**: QEMU redirects serial to file +3. **Protocol Parser**: Engine parses binary messages in real-time +4. **Output Handlers**: Console and XML outputs generated +5. **Result Aggregation**: Final TestResults with all test outcomes + +## Performance + +- **x64 Build**: ~60s (kernel compilation) +- **x64 Execution**: 2-5s (typical test suite) +- **ARM64 Build**: ~70s (kernel compilation) +- **ARM64 Execution**: 5-10s (slower boot)