From 6041acb8ed821ae9918009edd44fbf0b817bf779 Mon Sep 17 00:00:00 2001 From: prixod Date: Mon, 27 Oct 2025 21:28:46 +0400 Subject: [PATCH] remove k8s configs, update worker for multi-languages support, add local-submit option --- README.md | 258 +++++++++++++++--- compose.yaml | 14 +- global.json | 2 +- k8s/README.md | 92 ------- k8s/configmap.yaml | 37 --- k8s/gateway-deployment.yaml | 63 ----- k8s/namespace.yaml | 4 - k8s/worker-cpp-deployment.yaml | 82 ------ .../Controllers/TesterController.cs | 53 ++++ .../Models/LocalSubmitModel.cs | 12 + .../Services/WorkerClientService.cs | 1 + .../appsettings.json | 9 +- .../Controllers/TestController.cs | 54 +++- src/LiquidCode.Tester.Worker/Dockerfile | 32 ++- .../Models/LanguageConfig.cs | 15 + src/LiquidCode.Tester.Worker/Program.cs | 20 +- .../Services/CSharpCompilationService.cs | 117 ++++++++ .../Services/CSharpExecutionService.cs | 108 ++++++++ .../Services/CallbackService.cs | 40 +++ .../Services/CompilationServiceFactory.cs | 30 ++ .../Services/CppCompilationService.cs | 40 ++- .../Services/ExecutionServiceFactory.cs | 30 ++ .../Services/ICompilationService.cs | 3 +- .../Services/ICompilationServiceFactory.cs | 6 + .../Services/IExecutionServiceFactory.cs | 6 + .../Services/JavaCompilationService.cs | 119 ++++++++ .../Services/JavaExecutionService.cs | 111 ++++++++ .../Services/KotlinCompilationService.cs | 119 ++++++++ .../Services/KotlinExecutionService.cs | 110 ++++++++ .../Services/PythonCompilationService.cs | 74 +++++ .../Services/PythonExecutionService.cs | 114 ++++++++ .../Services/TestingService.cs | 29 +- src/LiquidCode.Tester.Worker/appsettings.json | 79 +++++- .../CompilationServiceFactoryTests.cs | 128 +++++++++ .../ExecutionServiceFactoryTests.cs | 128 +++++++++ .../LiquidCode.Tester.Worker.Tests.csproj | 29 ++ .../OutputCheckerServiceTests.cs | 169 ++++++++++++ .../PackageParserServiceTests.cs | 210 ++++++++++++++ 38 files changed, 2205 insertions(+), 342 deletions(-) delete mode 100644 k8s/README.md delete mode 100644 k8s/configmap.yaml delete mode 100644 k8s/gateway-deployment.yaml delete mode 100644 k8s/namespace.yaml delete mode 100644 k8s/worker-cpp-deployment.yaml create mode 100644 src/LiquidCode.Tester.Gateway/Models/LocalSubmitModel.cs create mode 100644 src/LiquidCode.Tester.Worker/Models/LanguageConfig.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/CSharpCompilationService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/CSharpExecutionService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/CompilationServiceFactory.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/ExecutionServiceFactory.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/ICompilationServiceFactory.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/IExecutionServiceFactory.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/JavaCompilationService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/JavaExecutionService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/KotlinCompilationService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/KotlinExecutionService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/PythonCompilationService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/PythonExecutionService.cs create mode 100644 tests/LiquidCode.Tester.Worker.Tests/CompilationServiceFactoryTests.cs create mode 100644 tests/LiquidCode.Tester.Worker.Tests/ExecutionServiceFactoryTests.cs create mode 100644 tests/LiquidCode.Tester.Worker.Tests/LiquidCode.Tester.Worker.Tests.csproj create mode 100644 tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs create mode 100644 tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs diff --git a/README.md b/README.md index 9e7d686..c8c7c3f 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ - Скачивание пакетов задач из Polygon - Маршрутизация запросов к соответствующим Worker'ам по языку программирования -### 2. Worker (Тестировщик) -- **Технология**: ASP.NET Core Web API + компиляторы языков +### 2. Worker (Универсальный тестировщик) +- **Технология**: ASP.NET Core Web API + компиляторы всех поддерживаемых языков - **Функции**: - - Компиляция пользовательского кода + - Динамический выбор компилятора/интерпретатора на основе языка + - Компиляция пользовательского кода (C++, Java, Kotlin, C#) или подготовка к исполнению (Python) - Запуск в изолированной среде - Тестирование на наборе тестов - Контроль ограничений по времени и памяти @@ -28,10 +29,11 @@ ## Поддерживаемые языки -- **C++** (текущая реализация) -- Java (планируется) -- Kotlin (планируется) -- C# (планируется) +- **C++** (реализовано) +- **Java** (реализовано) +- **Kotlin** (реализовано) +- **C#** (реализовано) +- **Python** (реализовано) ## Модель данных @@ -87,11 +89,71 @@ cd src/LiquidCode.Tester.Worker dotnet run ``` +## Локальное тестирование + +Для тестирования системы без внешних зависимостей используйте endpoint `/api/Tester/submit-local`: + +### 1. Подготовка тестового пакета + +Создайте ZIP файл с тестами в формате: +``` +test-package.zip +├── 1.in # Входные данные для теста 1 +├── 1.out # Ожидаемый результат для теста 1 +├── 2.in # Входные данные для теста 2 +├── 2.out # Ожидаемый результат для теста 2 +└── ... +``` + +### 2. Отправка запроса + +```bash +curl -X 'POST' \ + 'http://localhost:8080/api/Tester/submit-local' \ + -F 'id=1' \ + -F 'missionId=1' \ + -F 'language=python' \ + -F 'languageVersion=3.11' \ + -F 'sourceCode=n = int(input()) +print(n * 2)' \ + -F 'callbackUrl=log' \ + -F 'package=@./test-package.zip' +``` + +### 3. Просмотр результатов + +При использовании `callbackUrl=log` результаты тестирования выводятся в консоль Worker: + +``` +╔═══════════════════════════════════════════════════════════════╗ +║ CALLBACK RESULT ║ +╠═══════════════════════════════════════════════════════════════╣ +{ + "submitId": 1, + "state": "Done", + "errorCode": "None", + "message": "All tests passed", + "currentTest": 3, + "amountOfTests": 3 +} +╚═══════════════════════════════════════════════════════════════╝ +``` + +### Специальные значения callbackUrl + +- `"log"` - Вывод результатов в логи Worker +- `"console"` - Аналогично "log" +- `"log://"` - Аналогично "log" +- Любой HTTP URL - Отправка результатов на указанный endpoint + ## API Endpoints ### Gateway **POST /api/tester/submit** + +Отправка решения с удаленным пакетом тестов: + ```json { "id": 123, @@ -104,6 +166,31 @@ dotnet run } ``` +**POST /api/tester/submit-local** + +Отправка решения с локальным пакетом тестов (для отладки): + +```bash +curl -X 'POST' \ + 'http://localhost:8080/api/Tester/submit-local' \ + -F 'id=1' \ + -F 'missionId=1' \ + -F 'language=python' \ + -F 'languageVersion=3.11' \ + -F 'sourceCode=print("Hello, World!")' \ + -F 'callbackUrl=log' \ + -F 'package=@/path/to/test-package.zip' +``` + +**Параметры:** +- `id` - ID сабмита +- `missionId` - ID задачи +- `language` - Язык программирования (C++, Java, Kotlin, C#, Python) +- `languageVersion` - Версия языка (опционально, используется "latest" по умолчанию) +- `sourceCode` - Исходный код решения +- `callbackUrl` - URL для отправки результатов или `"log"` для вывода в консоль +- `package` - ZIP файл с тестами + **GET /api/tester/health** - Проверка состояния Gateway @@ -121,7 +208,7 @@ dotnet run 2. **Скачивание пакета**: Gateway скачивает Polygon пакет 3. **Маршрутизация**: Gateway отправляет запрос в Worker для нужного языка 4. **Парсинг**: Worker распаковывает и парсит пакет (тесты, лимиты) -5. **Компиляция**: Worker компилирует код (g++ для C++) +5. **Компиляция**: Worker выбирает нужный компилятор на основе языка и компилирует код 6. **Тестирование**: Worker последовательно запускает все тесты 7. **Callback**: Worker отправляет статусы на callback URL на каждом этапе 8. **Cleanup**: Worker удаляет временные файлы @@ -141,17 +228,6 @@ dotnet run - chroot окружение - Отключение доступа к сети для тестируемого кода -## Деплой в Kubernetes - -См. [k8s/README.md](k8s/README.md) для подробной инструкции. - -```bash -# Быстрый деплой -kubectl apply -f k8s/namespace.yaml -kubectl apply -f k8s/configmap.yaml -kubectl apply -f k8s/worker-cpp-deployment.yaml -kubectl apply -f k8s/gateway-deployment.yaml -``` ## Конфигурация @@ -160,7 +236,11 @@ kubectl apply -f k8s/gateway-deployment.yaml { "PackageDownloadDirectory": "/tmp/packages", "Workers": { - "Cpp": "http://liquidcode-tester-worker-cpp:8080" + "Cpp": "http://localhost:8081", + "Java": "http://localhost:8081", + "Kotlin": "http://localhost:8081", + "CSharp": "http://localhost:8081", + "Python": "http://localhost:8081" } } ``` @@ -170,11 +250,115 @@ kubectl apply -f k8s/gateway-deployment.yaml { "Cpp": { "Compiler": "g++", - "CompilerFlags": "-O2 -std=c++17 -Wall" + "CompilerFlags": "-O2 -std=c++17 -Wall", + "Versions": { + "14": { + "Compiler": "g++", + "CompilerFlags": "-O2 -std=c++14 -Wall" + }, + "17": { + "Compiler": "g++", + "CompilerFlags": "-O2 -std=c++17 -Wall" + }, + "20": { + "Compiler": "g++", + "CompilerFlags": "-O2 -std=c++20 -Wall" + } + } + }, + "Java": { + "Compiler": "javac", + "CompilerFlags": "", + "Versions": { + "8": { + "Compiler": "javac", + "CompilerFlags": "-source 8 -target 8" + }, + "11": { + "Compiler": "javac", + "CompilerFlags": "-source 11 -target 11" + }, + "17": { + "Compiler": "javac", + "CompilerFlags": "" + } + } + }, + "Kotlin": { + "Compiler": "kotlinc", + "CompilerFlags": "", + "Versions": { + "1.9": { + "Compiler": "kotlinc", + "CompilerFlags": "" + } + } + }, + "CSharp": { + "Compiler": "csc", + "CompilerFlags": "/optimize+", + "Versions": { + "7": { + "Compiler": "csc", + "CompilerFlags": "/optimize+ /langversion:7" + }, + "8": { + "Compiler": "csc", + "CompilerFlags": "/optimize+ /langversion:8" + }, + "9": { + "Compiler": "csc", + "CompilerFlags": "/optimize+ /langversion:9" + } + } + }, + "Python": { + "Executable": "python3", + "Versions": { + "3.8": { + "Executable": "python3.8" + }, + "3.9": { + "Executable": "python3.9" + }, + "3.10": { + "Executable": "python3.10" + }, + "3.11": { + "Executable": "python3.11" + } + } } } ``` +#### Поддержка версий языков + +Система поддерживает указание версии языка программирования через параметр `languageVersion`: + +- **"latest"** или **null** - использует версию по умолчанию из основной конфигурации +- **Конкретная версия** (например, "17", "3.11") - использует конфигурацию из секции `Versions` +- **Несуществующая версия** - логируется предупреждение и используется версия по умолчанию + +Каждый язык имеет свою конфигурацию с возможностью указания: +- Для компилируемых языков (C++, Java, Kotlin, C#): путь к компилятору и флаги компиляции +- Для интерпретируемых языков (Python): путь к интерпретатору + +Пример использования: +```bash +# Использование Python 3.11 +curl -X POST 'http://localhost:8080/api/Tester/submit-local' \ + -F 'languageVersion=3.11' \ + -F 'language=python' \ + ... + +# Использование C++20 +curl -X POST 'http://localhost:8080/api/Tester/submit-local' \ + -F 'languageVersion=20' \ + -F 'language=C++' \ + ... +``` + ## Структура проекта ``` @@ -186,11 +370,13 @@ LiquidCode.Tester/ │ │ ├── Controllers/ │ │ ├── Services/ │ │ └── Dockerfile -│ └── LiquidCode.Tester.Worker/ # C++ Worker +│ └── LiquidCode.Tester.Worker/ # Универсальный Worker (все языки) │ ├── Controllers/ │ ├── Services/ +│ │ ├── Compilation/ # Компиляторы +│ │ ├── Execution/ # Исполнители +│ │ └── Factories/ # Фабрики │ └── Dockerfile -├── k8s/ # Kubernetes манифесты ├── compose.yaml # Docker Compose └── README.md ``` @@ -199,19 +385,29 @@ LiquidCode.Tester/ - .NET 9.0 SDK - Docker & Docker Compose (для контейнеризации) -- g++ (для C++ Worker) -- Kubernetes cluster (для production) +- Компиляторы языков программирования (устанавливаются автоматически в Docker): + - g++ (C++) + - OpenJDK 17 (Java) + - Kotlin compiler (Kotlin) + - Mono (C#) + - Python 3 ## Расширение на другие языки Для добавления поддержки нового языка: -1. Создайте новый Worker проект (или расширьте существующий) -2. Реализуйте `ICompilationService` для языка -3. Реализуйте `IExecutionService` для языка -4. Обновите конфигурацию Gateway -5. Создайте Dockerfile с нужным компилятором/runtime -6. Добавьте Kubernetes манифесты +1. Создайте новую реализацию `ICompilationService` для языка (например, `GoCompilationService.cs`) +2. Создайте новую реализацию `IExecutionService` для языка (например, `GoExecutionService.cs`) +3. Зарегистрируйте новые сервисы в `Program.cs`: + ```csharp + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + ``` +4. Обновите фабрики (`CompilationServiceFactory` и `ExecutionServiceFactory`), добавив поддержку нового языка +5. Добавьте конфигурацию языка в `appsettings.json` Worker +6. Обновите `Dockerfile` Worker, добавив установку компилятора/runtime +7. Добавьте конфигурацию воркера для нового языка в `appsettings.json` Gateway +8. Обновите `WorkerClientService` Gateway, добавив маппинг языка на URL воркера ## Разработка diff --git a/compose.yaml b/compose.yaml index 5eedec5..764e942 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,15 +9,19 @@ - "8080:8080" environment: - ASPNETCORE_ENVIRONMENT=Development - - Workers__Cpp=http://worker-cpp:8080 + - Workers__Cpp=http://worker:8080 + - Workers__Java=http://worker:8080 + - Workers__Kotlin=http://worker:8080 + - Workers__CSharp=http://worker:8080 + - Workers__Python=http://worker:8080 networks: - liquidcode-network depends_on: - - worker-cpp + - worker - worker-cpp: - image: liquidcode-tester-worker-cpp:latest - container_name: liquidcode-tester-worker-cpp + worker: + image: liquidcode-tester-worker:latest + container_name: liquidcode-tester-worker build: context: . dockerfile: src/LiquidCode.Tester.Worker/Dockerfile diff --git a/global.json b/global.json index 9a247ed..41076f3 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "9.0.0", - "rollForward": "latestMinor" + "rollForward": "latestMajor" } } \ No newline at end of file diff --git a/k8s/README.md b/k8s/README.md deleted file mode 100644 index dd0bffc..0000000 --- a/k8s/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Kubernetes Deployment - -## Prerequisites - -- Kubernetes cluster (minikube, kind, or cloud provider) -- kubectl configured -- Docker images built and available - -## Building Docker Images - -```bash -# Build Gateway image -docker build -t liquidcode-tester-gateway:latest -f src/LiquidCode.Tester.Gateway/Dockerfile . - -# Build C++ Worker image -docker build -t liquidcode-tester-worker-cpp:latest -f src/LiquidCode.Tester.Worker/Dockerfile . -``` - -## Deploying to Kubernetes - -```bash -# Create namespace -kubectl apply -f k8s/namespace.yaml - -# Apply ConfigMap -kubectl apply -f k8s/configmap.yaml - -# Deploy Worker (must be deployed first) -kubectl apply -f k8s/worker-cpp-deployment.yaml - -# Deploy Gateway -kubectl apply -f k8s/gateway-deployment.yaml -``` - -## Checking Status - -```bash -# Check all resources -kubectl get all -n liquidcode-tester - -# Check pods -kubectl get pods -n liquidcode-tester - -# Check services -kubectl get services -n liquidcode-tester - -# View logs -kubectl logs -n liquidcode-tester -l app=gateway -kubectl logs -n liquidcode-tester -l app=worker-cpp -``` - -## Access the Gateway - -```bash -# Get the external IP (for LoadBalancer) -kubectl get service liquidcode-tester-gateway -n liquidcode-tester - -# For minikube -minikube service liquidcode-tester-gateway -n liquidcode-tester - -# Port forward (alternative) -kubectl port-forward -n liquidcode-tester service/liquidcode-tester-gateway 8080:80 -``` - -## Scaling Workers - -```bash -# Scale C++ workers -kubectl scale deployment liquidcode-tester-worker-cpp -n liquidcode-tester --replicas=5 -``` - -## Cleanup - -```bash -# Delete all resources -kubectl delete namespace liquidcode-tester -``` - -## Production Considerations - -1. **Image Registry**: Push images to a container registry (Docker Hub, GCR, ECR, etc.) -2. **Resource Limits**: Adjust CPU/Memory limits based on workload -3. **Persistent Storage**: Add PersistentVolumes for package storage if needed -4. **Monitoring**: Add Prometheus/Grafana for metrics -5. **Logging**: Configure centralized logging (ELK, Loki, etc.) -6. **Security**: - - Use NetworkPolicies to restrict traffic - - Enable Pod Security Standards - - Use secrets for sensitive data - - Consider using a service mesh (Istio, Linkerd) -7. **Autoscaling**: Configure HorizontalPodAutoscaler for workers -8. **Ingress**: Use Ingress controller instead of LoadBalancer for production diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml deleted file mode 100644 index 3c76dc7..0000000 --- a/k8s/configmap.yaml +++ /dev/null @@ -1,37 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: liquidcode-tester-config - namespace: liquidcode-tester -data: - gateway.appsettings.json: | - { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "PackageDownloadDirectory": "/tmp/packages", - "Workers": { - "Cpp": "http://liquidcode-tester-worker-cpp:8080", - "Java": "http://liquidcode-tester-worker-java:8080", - "Kotlin": "http://liquidcode-tester-worker-kotlin:8080", - "CSharp": "http://liquidcode-tester-worker-csharp:8080" - } - } - worker.appsettings.json: | - { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "Cpp": { - "Compiler": "g++", - "CompilerFlags": "-O2 -std=c++17 -Wall" - } - } diff --git a/k8s/gateway-deployment.yaml b/k8s/gateway-deployment.yaml deleted file mode 100644 index 817c08d..0000000 --- a/k8s/gateway-deployment.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: liquidcode-tester-gateway - namespace: liquidcode-tester - labels: - app: gateway -spec: - replicas: 2 - selector: - matchLabels: - app: gateway - template: - metadata: - labels: - app: gateway - spec: - containers: - - name: gateway - image: liquidcode-tester-gateway:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: ASPNETCORE_ENVIRONMENT - value: "Production" - - name: Workers__Cpp - value: "http://liquidcode-tester-worker-cpp:8080" - resources: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "512Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: /api/tester/health - port: 8080 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /api/tester/health - port: 8080 - initialDelaySeconds: 5 - periodSeconds: 10 ---- -apiVersion: v1 -kind: Service -metadata: - name: liquidcode-tester-gateway - namespace: liquidcode-tester -spec: - type: LoadBalancer - selector: - app: gateway - ports: - - port: 80 - targetPort: 8080 - protocol: TCP - name: http diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml deleted file mode 100644 index 662aaf4..0000000 --- a/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: liquidcode-tester diff --git a/k8s/worker-cpp-deployment.yaml b/k8s/worker-cpp-deployment.yaml deleted file mode 100644 index 0de837e..0000000 --- a/k8s/worker-cpp-deployment.yaml +++ /dev/null @@ -1,82 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: liquidcode-tester-worker-cpp - namespace: liquidcode-tester - labels: - app: worker-cpp - language: cpp -spec: - replicas: 3 - selector: - matchLabels: - app: worker-cpp - template: - metadata: - labels: - app: worker-cpp - language: cpp - spec: - containers: - - name: worker-cpp - image: liquidcode-tester-worker-cpp:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: ASPNETCORE_ENVIRONMENT - value: "Production" - - name: Cpp__Compiler - value: "g++" - - name: Cpp__CompilerFlags - value: "-O2 -std=c++17 -Wall" - resources: - requests: - memory: "256Mi" - cpu: "200m" - limits: - memory: "2Gi" - cpu: "1000m" - livenessProbe: - httpGet: - path: /api/test/health - port: 8080 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /api/test/health - port: 8080 - initialDelaySeconds: 5 - periodSeconds: 10 - # Security context for isolation - securityContext: - allowPrivilegeEscalation: false - runAsNonRoot: true - runAsUser: 1000 - capabilities: - drop: - - ALL - readOnlyRootFilesystem: false - volumeMounts: - - name: tmp - mountPath: /tmp - volumes: - - name: tmp - emptyDir: {} ---- -apiVersion: v1 -kind: Service -metadata: - name: liquidcode-tester-worker-cpp - namespace: liquidcode-tester -spec: - type: ClusterIP - selector: - app: worker-cpp - ports: - - port: 8080 - targetPort: 8080 - protocol: TCP - name: http diff --git a/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs b/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs index b94527f..9b22213 100644 --- a/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs +++ b/src/LiquidCode.Tester.Gateway/Controllers/TesterController.cs @@ -1,4 +1,5 @@ using LiquidCode.Tester.Common.Models; +using LiquidCode.Tester.Gateway.Models; using LiquidCode.Tester.Gateway.Services; using Microsoft.AspNetCore.Mvc; @@ -44,6 +45,58 @@ public class TesterController : ControllerBase } } + [HttpPost("submit-local")] + public async Task SubmitLocal([FromForm] LocalSubmitModel request) + { + _logger.LogInformation("Received local submit request for ID {SubmitId}", request.Id); + + try + { + if (request.Package == null) + { + return BadRequest(new { error = "Package file is required" }); + } + + // Save uploaded package to temporary file + var packageDirectory = Path.Combine(Path.GetTempPath(), "packages"); + if (!Directory.Exists(packageDirectory)) + { + Directory.CreateDirectory(packageDirectory); + } + + var packageFileName = $"package_{Guid.NewGuid()}.zip"; + var packagePath = Path.Combine(packageDirectory, packageFileName); + + await using (var fileStream = new FileStream(packagePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await request.Package.CopyToAsync(fileStream); + } + + _logger.LogInformation("Package saved to {PackagePath}", packagePath); + + // Create SubmitForTesterModel from local submit + var submitModel = new SubmitForTesterModel( + Id: request.Id, + MissionId: request.MissionId, + Language: request.Language, + LanguageVersion: request.LanguageVersion, + SourceCode: request.SourceCode, + PackageUrl: packagePath, // Use local path instead of URL + CallbackUrl: request.CallbackUrl + ); + + // Send to appropriate worker based on language + await _workerClientService.SendToWorkerAsync(submitModel, packagePath); + + return Accepted(new { message = "Submit accepted for testing", submitId = request.Id }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process local submit {SubmitId}", request.Id); + return StatusCode(500, new { error = "Failed to process submit", details = ex.Message }); + } + } + [HttpGet("health")] public IActionResult Health() { diff --git a/src/LiquidCode.Tester.Gateway/Models/LocalSubmitModel.cs b/src/LiquidCode.Tester.Gateway/Models/LocalSubmitModel.cs new file mode 100644 index 0000000..b150508 --- /dev/null +++ b/src/LiquidCode.Tester.Gateway/Models/LocalSubmitModel.cs @@ -0,0 +1,12 @@ +namespace LiquidCode.Tester.Gateway.Models; + +public class LocalSubmitModel +{ + public long Id { get; set; } + public long MissionId { get; set; } + public string Language { get; set; } = string.Empty; + public string LanguageVersion { get; set; } = string.Empty; + public string SourceCode { get; set; } = string.Empty; + public string CallbackUrl { get; set; } = string.Empty; + public IFormFile? Package { get; set; } +} diff --git a/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs b/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs index 41da9ab..ca07a60 100644 --- a/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs +++ b/src/LiquidCode.Tester.Gateway/Services/WorkerClientService.cs @@ -79,6 +79,7 @@ public class WorkerClientService : IWorkerClientService "java" => _configuration["Workers:Java"], "kotlin" => _configuration["Workers:Kotlin"], "c#" => _configuration["Workers:CSharp"], + "python" => _configuration["Workers:Python"], _ => throw new NotSupportedException($"Language {language} is not supported") }; diff --git a/src/LiquidCode.Tester.Gateway/appsettings.json b/src/LiquidCode.Tester.Gateway/appsettings.json index 3f0f92b..bd1d007 100644 --- a/src/LiquidCode.Tester.Gateway/appsettings.json +++ b/src/LiquidCode.Tester.Gateway/appsettings.json @@ -8,9 +8,10 @@ "AllowedHosts": "*", "PackageDownloadDirectory": "/tmp/packages", "Workers": { - "Cpp": "http://liquidcode-tester-worker-cpp:8080", - "Java": "http://liquidcode-tester-worker-java:8080", - "Kotlin": "http://liquidcode-tester-worker-kotlin:8080", - "CSharp": "http://liquidcode-tester-worker-csharp:8080" + "Cpp": "http://localhost:8081", + "Java": "http://localhost:8081", + "Kotlin": "http://localhost:8081", + "CSharp": "http://localhost:8081", + "Python": "http://localhost:8081" } } diff --git a/src/LiquidCode.Tester.Worker/Controllers/TestController.cs b/src/LiquidCode.Tester.Worker/Controllers/TestController.cs index a9e2778..d2fbe14 100644 --- a/src/LiquidCode.Tester.Worker/Controllers/TestController.cs +++ b/src/LiquidCode.Tester.Worker/Controllers/TestController.cs @@ -23,16 +23,63 @@ public class TestController : ControllerBase try { + // Save package to temporary file before starting background task + // This is necessary because IFormFile becomes unavailable after request completes + string? packageFilePath = null; + if (request.Package != null) + { + var tempDirectory = Path.Combine(Path.GetTempPath(), "worker-packages"); + Directory.CreateDirectory(tempDirectory); + + packageFilePath = Path.Combine(tempDirectory, $"package_{Guid.NewGuid()}.zip"); + + await using (var fileStream = new FileStream(packageFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await request.Package.CopyToAsync(fileStream); + } + + _logger.LogInformation("Package saved to temporary file: {FilePath}", packageFilePath); + } + + // Create a copy of request data for background task + var backgroundRequest = new TestRequest + { + Id = request.Id, + MissionId = request.MissionId, + Language = request.Language, + LanguageVersion = request.LanguageVersion, + SourceCode = request.SourceCode, + CallbackUrl = request.CallbackUrl, + Package = null, // Will use file path instead + PackageFilePath = packageFilePath + }; + // Start testing in background _ = Task.Run(async () => { try { - await _testingService.ProcessSubmitAsync(request); + await _testingService.ProcessSubmitAsync(backgroundRequest); } catch (Exception ex) { - _logger.LogError(ex, "Error processing submit {SubmitId}", request.Id); + _logger.LogError(ex, "Error processing submit {SubmitId}", backgroundRequest.Id); + } + finally + { + // Cleanup temporary package file + if (packageFilePath != null && System.IO.File.Exists(packageFilePath)) + { + try + { + System.IO.File.Delete(packageFilePath); + _logger.LogInformation("Deleted temporary package file: {FilePath}", packageFilePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete temporary package file: {FilePath}", packageFilePath); + } + } } }); @@ -48,7 +95,7 @@ public class TestController : ControllerBase [HttpGet("health")] public IActionResult Health() { - return Ok(new { status = "healthy", service = "cpp-worker", timestamp = DateTime.UtcNow }); + return Ok(new { status = "healthy", service = "universal-worker", timestamp = DateTime.UtcNow }); } } @@ -61,4 +108,5 @@ public class TestRequest public string SourceCode { get; set; } = string.Empty; public string CallbackUrl { get; set; } = string.Empty; public IFormFile? Package { get; set; } + public string? PackageFilePath { get; set; } // Internal use - path to saved package file } diff --git a/src/LiquidCode.Tester.Worker/Dockerfile b/src/LiquidCode.Tester.Worker/Dockerfile index c9392e1..f1f444f 100644 --- a/src/LiquidCode.Tester.Worker/Dockerfile +++ b/src/LiquidCode.Tester.Worker/Dockerfile @@ -24,16 +24,44 @@ FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "./LiquidCode.Tester.Worker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false -# Final stage - use aspnet runtime with C++ compiler +# Final stage - use aspnet runtime with all compilers FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final WORKDIR /app -# Install C++ compiler and build tools +# Install compilers and runtimes for all supported languages RUN apt-get update && \ apt-get install -y --no-install-recommends \ + # C++ compiler and build tools g++ \ gcc \ make \ + # Java Development Kit and Runtime + openjdk-17-jdk \ + # Python + python3 \ + python3-pip \ + # Kotlin compiler + wget \ + unzip \ + && wget -q https://github.com/JetBrains/kotlin/releases/download/v1.9.20/kotlin-compiler-1.9.20.zip -O /tmp/kotlin.zip \ + && unzip -q /tmp/kotlin.zip -d /opt \ + && rm /tmp/kotlin.zip \ + && ln -s /opt/kotlinc/bin/kotlinc /usr/local/bin/kotlinc \ + && ln -s /opt/kotlinc/bin/kotlin /usr/local/bin/kotlin \ + # Cleanup + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install Mono for C# compilation (csc) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + gnupg \ + && gpg --homedir /tmp --no-default-keyring --keyring /usr/share/keyrings/mono-official-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF \ + && echo "deb [signed-by=/usr/share/keyrings/mono-official-archive-keyring.gpg] https://download.mono-project.com/repo/debian stable-buster main" | tee /etc/apt/sources.list.d/mono-official-stable.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends mono-devel \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Copy published app diff --git a/src/LiquidCode.Tester.Worker/Models/LanguageConfig.cs b/src/LiquidCode.Tester.Worker/Models/LanguageConfig.cs new file mode 100644 index 0000000..27bf9c7 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Models/LanguageConfig.cs @@ -0,0 +1,15 @@ +namespace LiquidCode.Tester.Worker.Models; + +public class LanguageConfig +{ + public string DefaultVersion { get; set; } = "latest"; + public Dictionary Versions { get; set; } = new(); +} + +public class VersionConfig +{ + public string? Compiler { get; set; } + public string? CompilerFlags { get; set; } + public string? Executable { get; set; } + public string? Runtime { get; set; } +} diff --git a/src/LiquidCode.Tester.Worker/Program.cs b/src/LiquidCode.Tester.Worker/Program.cs index 6083e18..d46cee5 100644 --- a/src/LiquidCode.Tester.Worker/Program.cs +++ b/src/LiquidCode.Tester.Worker/Program.cs @@ -11,10 +11,26 @@ builder.Services.AddHttpClient(); // Register application services builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + +// Register compilation services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Register execution services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Register testing service builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/src/LiquidCode.Tester.Worker/Services/CSharpCompilationService.cs b/src/LiquidCode.Tester.Worker/Services/CSharpCompilationService.cs new file mode 100644 index 0000000..58215c9 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/CSharpCompilationService.cs @@ -0,0 +1,117 @@ +using System.Diagnostics; +using LiquidCode.Tester.Worker.Models; + +namespace LiquidCode.Tester.Worker.Services; + +public class CSharpCompilationService : ICompilationService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public CSharpCompilationService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + public async Task CompileAsync(string sourceCode, string workingDirectory, string? version = null) + { + var sourceFilePath = Path.Combine(workingDirectory, "Solution.cs"); + var executablePath = Path.Combine(workingDirectory, "solution.exe"); + + _logger.LogInformation("Compiling C# code in {WorkingDirectory} with version {Version}", workingDirectory, version ?? "latest"); + + try + { + await File.WriteAllTextAsync(sourceFilePath, sourceCode); + + // Resolve version-specific configuration + var (compiler, compilerFlags) = ResolveVersion(version); + + _logger.LogDebug("Using compiler: {Compiler} with flags: {Flags}", compiler, compilerFlags); + + var arguments = $"{compilerFlags} /out:\"{executablePath}\" \"{sourceFilePath}\""; + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = compiler, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + var compilerOutput = $"{output}\n{error}".Trim(); + + if (process.ExitCode == 0 && File.Exists(executablePath)) + { + _logger.LogInformation("C# compilation successful"); + return new CompilationResult + { + Success = true, + ExecutablePath = executablePath, + CompilerOutput = compilerOutput + }; + } + else + { + _logger.LogWarning("C# compilation failed with exit code {ExitCode}", process.ExitCode); + return new CompilationResult + { + Success = false, + ErrorMessage = "Compilation failed", + CompilerOutput = compilerOutput + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during C# compilation"); + return new CompilationResult + { + Success = false, + ErrorMessage = $"Compilation error: {ex.Message}" + }; + } + } + + private (string compiler, string compilerFlags) ResolveVersion(string? version) + { + // If version is null or "latest", use default configuration + if (string.IsNullOrEmpty(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase)) + { + var compiler = _configuration["CSharp:Compiler"] ?? "csc"; + var compilerFlags = _configuration["CSharp:CompilerFlags"] ?? "/optimize+"; + return (compiler, compilerFlags); + } + + // Try to find version-specific configuration + var versionKey = $"CSharp:Versions:{version}"; + var versionCompiler = _configuration[$"{versionKey}:Compiler"]; + var versionFlags = _configuration[$"{versionKey}:CompilerFlags"]; + + if (!string.IsNullOrEmpty(versionCompiler)) + { + _logger.LogInformation("Using C# version {Version} configuration", version); + return (versionCompiler, versionFlags ?? "/optimize+"); + } + + // Version not found, use default and log warning + _logger.LogWarning("C# version {Version} not found in configuration, using default", version); + var defaultCompiler = _configuration["CSharp:Compiler"] ?? "csc"; + var defaultFlags = _configuration["CSharp:CompilerFlags"] ?? "/optimize+"; + return (defaultCompiler, defaultFlags); + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/CSharpExecutionService.cs b/src/LiquidCode.Tester.Worker/Services/CSharpExecutionService.cs new file mode 100644 index 0000000..53fdc16 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/CSharpExecutionService.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; + +namespace LiquidCode.Tester.Worker.Services; + +public class CSharpExecutionService : IExecutionService +{ + private readonly ILogger _logger; + + public CSharpExecutionService(ILogger logger) + { + _logger = logger; + } + + public async Task ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb) + { + _logger.LogInformation("Executing C# executable {Executable} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB", + executablePath, inputFilePath, timeLimitMs, memoryLimitMb); + + var result = new ExecutionResult(); + var stopwatch = Stopwatch.StartNew(); + + try + { + using var inputStream = File.OpenRead(inputFilePath); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = executablePath, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + await inputStream.CopyToAsync(process.StandardInput.BaseStream); + process.StandardInput.Close(); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs)); + + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + + if (!completedInTime) + { + try + { + process.Kill(entireProcessTree: true); + } + catch { } + + result.TimeLimitExceeded = true; + result.ErrorMessage = "Time limit exceeded"; + _logger.LogWarning("Execution exceeded time limit"); + return result; + } + + result.Output = await outputTask; + result.ErrorOutput = await errorTask; + result.ExitCode = process.ExitCode; + + if (process.ExitCode != 0) + { + result.RuntimeError = true; + result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})"; + _logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode); + return result; + } + + try + { + result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024); + if (result.MemoryUsedMb > memoryLimitMb) + { + result.MemoryLimitExceeded = true; + result.ErrorMessage = "Memory limit exceeded"; + _logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb); + return result; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not measure memory usage"); + } + + result.Success = true; + _logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs); + } + catch (Exception ex) + { + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + result.RuntimeError = true; + result.ErrorMessage = $"Execution error: {ex.Message}"; + _logger.LogError(ex, "Error during execution"); + } + + return result; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/CallbackService.cs b/src/LiquidCode.Tester.Worker/Services/CallbackService.cs index fc482eb..cef0004 100644 --- a/src/LiquidCode.Tester.Worker/Services/CallbackService.cs +++ b/src/LiquidCode.Tester.Worker/Services/CallbackService.cs @@ -17,6 +17,13 @@ public class CallbackService : ICallbackService public async Task SendStatusAsync(string callbackUrl, TesterResponseModel response) { + // Check if callback should be logged instead of sent via HTTP + if (IsLogCallback(callbackUrl)) + { + LogCallback(response); + return; + } + _logger.LogInformation("Sending status update to {CallbackUrl} for submit {SubmitId}", callbackUrl, response.SubmitId); try @@ -36,4 +43,37 @@ public class CallbackService : ICallbackService // Don't throw - callback failures shouldn't stop testing } } + + private bool IsLogCallback(string callbackUrl) + { + if (string.IsNullOrWhiteSpace(callbackUrl)) + { + return false; + } + + var normalized = callbackUrl.Trim().ToLowerInvariant(); + return normalized == "log" || + normalized == "console" || + normalized.StartsWith("log://"); + } + + private void LogCallback(TesterResponseModel response) + { + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var json = JsonSerializer.Serialize(response, options); + + _logger.LogInformation( + "\n" + + "╔═══════════════════════════════════════════════════════════════╗\n" + + "║ CALLBACK RESULT ║\n" + + "╠═══════════════════════════════════════════════════════════════╣\n" + + "{Json}\n" + + "╚═══════════════════════════════════════════════════════════════╝", + json); + } } diff --git a/src/LiquidCode.Tester.Worker/Services/CompilationServiceFactory.cs b/src/LiquidCode.Tester.Worker/Services/CompilationServiceFactory.cs new file mode 100644 index 0000000..f502384 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/CompilationServiceFactory.cs @@ -0,0 +1,30 @@ +namespace LiquidCode.Tester.Worker.Services; + +public class CompilationServiceFactory : ICompilationServiceFactory +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public CompilationServiceFactory(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public ICompilationService GetCompilationService(string language) + { + var normalizedLanguage = language.ToLowerInvariant().Replace(" ", ""); + + _logger.LogInformation("Getting compilation service for language: {Language}", normalizedLanguage); + + return normalizedLanguage switch + { + "c++" or "cpp" => _serviceProvider.GetRequiredService(), + "java" => _serviceProvider.GetRequiredService(), + "kotlin" => _serviceProvider.GetRequiredService(), + "c#" or "csharp" => _serviceProvider.GetRequiredService(), + "python" => _serviceProvider.GetRequiredService(), + _ => throw new NotSupportedException($"Language '{language}' is not supported") + }; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/CppCompilationService.cs b/src/LiquidCode.Tester.Worker/Services/CppCompilationService.cs index 6a3c6c5..b076563 100644 --- a/src/LiquidCode.Tester.Worker/Services/CppCompilationService.cs +++ b/src/LiquidCode.Tester.Worker/Services/CppCompilationService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using LiquidCode.Tester.Worker.Models; namespace LiquidCode.Tester.Worker.Services; @@ -13,21 +14,22 @@ public class CppCompilationService : ICompilationService _configuration = configuration; } - public async Task CompileAsync(string sourceCode, string workingDirectory) + public async Task CompileAsync(string sourceCode, string workingDirectory, string? version = null) { var sourceFilePath = Path.Combine(workingDirectory, "solution.cpp"); var executablePath = Path.Combine(workingDirectory, "solution"); - _logger.LogInformation("Compiling C++ code in {WorkingDirectory}", workingDirectory); + _logger.LogInformation("Compiling C++ code in {WorkingDirectory} with version {Version}", workingDirectory, version ?? "latest"); try { // Write source code to file await File.WriteAllTextAsync(sourceFilePath, sourceCode); - // Compile using g++ - var compiler = _configuration["Cpp:Compiler"] ?? "g++"; - var compilerFlags = _configuration["Cpp:CompilerFlags"] ?? "-O2 -std=c++17 -Wall"; + // Resolve version-specific configuration + var (compiler, compilerFlags) = ResolveVersion(version); + + _logger.LogDebug("Using compiler: {Compiler} with flags: {Flags}", compiler, compilerFlags); var process = new Process { @@ -83,4 +85,32 @@ public class CppCompilationService : ICompilationService }; } } + + private (string compiler, string compilerFlags) ResolveVersion(string? version) + { + // If version is null or "latest", use default configuration + if (string.IsNullOrEmpty(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase)) + { + var compiler = _configuration["Cpp:Compiler"] ?? "g++"; + var compilerFlags = _configuration["Cpp:CompilerFlags"] ?? "-O2 -std=c++17 -Wall"; + return (compiler, compilerFlags); + } + + // Try to find version-specific configuration + var versionKey = $"Cpp:Versions:{version}"; + var versionCompiler = _configuration[$"{versionKey}:Compiler"]; + var versionFlags = _configuration[$"{versionKey}:CompilerFlags"]; + + if (!string.IsNullOrEmpty(versionCompiler)) + { + _logger.LogInformation("Using C++ version {Version} configuration", version); + return (versionCompiler, versionFlags ?? "-O2 -Wall"); + } + + // Version not found, use default and log warning + _logger.LogWarning("C++ version {Version} not found in configuration, using default", version); + var defaultCompiler = _configuration["Cpp:Compiler"] ?? "g++"; + var defaultFlags = _configuration["Cpp:CompilerFlags"] ?? "-O2 -std=c++17 -Wall"; + return (defaultCompiler, defaultFlags); + } } diff --git a/src/LiquidCode.Tester.Worker/Services/ExecutionServiceFactory.cs b/src/LiquidCode.Tester.Worker/Services/ExecutionServiceFactory.cs new file mode 100644 index 0000000..eda5933 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/ExecutionServiceFactory.cs @@ -0,0 +1,30 @@ +namespace LiquidCode.Tester.Worker.Services; + +public class ExecutionServiceFactory : IExecutionServiceFactory +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public ExecutionServiceFactory(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public IExecutionService GetExecutionService(string language) + { + var normalizedLanguage = language.ToLowerInvariant().Replace(" ", ""); + + _logger.LogInformation("Getting execution service for language: {Language}", normalizedLanguage); + + return normalizedLanguage switch + { + "c++" or "cpp" => _serviceProvider.GetRequiredService(), + "java" => _serviceProvider.GetRequiredService(), + "kotlin" => _serviceProvider.GetRequiredService(), + "c#" or "csharp" => _serviceProvider.GetRequiredService(), + "python" => _serviceProvider.GetRequiredService(), + _ => throw new NotSupportedException($"Language '{language}' is not supported") + }; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/ICompilationService.cs b/src/LiquidCode.Tester.Worker/Services/ICompilationService.cs index f9405ef..3b9d094 100644 --- a/src/LiquidCode.Tester.Worker/Services/ICompilationService.cs +++ b/src/LiquidCode.Tester.Worker/Services/ICompilationService.cs @@ -7,8 +7,9 @@ public interface ICompilationService /// /// Source code to compile /// Directory to compile in + /// Language version (e.g., "17", "20", "latest"). If null or "latest", uses default version. /// Result containing success status, executable path, and error messages - Task CompileAsync(string sourceCode, string workingDirectory); + Task CompileAsync(string sourceCode, string workingDirectory, string? version = null); } public class CompilationResult diff --git a/src/LiquidCode.Tester.Worker/Services/ICompilationServiceFactory.cs b/src/LiquidCode.Tester.Worker/Services/ICompilationServiceFactory.cs new file mode 100644 index 0000000..1bcb94a --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/ICompilationServiceFactory.cs @@ -0,0 +1,6 @@ +namespace LiquidCode.Tester.Worker.Services; + +public interface ICompilationServiceFactory +{ + ICompilationService GetCompilationService(string language); +} diff --git a/src/LiquidCode.Tester.Worker/Services/IExecutionServiceFactory.cs b/src/LiquidCode.Tester.Worker/Services/IExecutionServiceFactory.cs new file mode 100644 index 0000000..118cfe3 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/IExecutionServiceFactory.cs @@ -0,0 +1,6 @@ +namespace LiquidCode.Tester.Worker.Services; + +public interface IExecutionServiceFactory +{ + IExecutionService GetExecutionService(string language); +} diff --git a/src/LiquidCode.Tester.Worker/Services/JavaCompilationService.cs b/src/LiquidCode.Tester.Worker/Services/JavaCompilationService.cs new file mode 100644 index 0000000..e7eba68 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/JavaCompilationService.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using LiquidCode.Tester.Worker.Models; + +namespace LiquidCode.Tester.Worker.Services; + +public class JavaCompilationService : ICompilationService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public JavaCompilationService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + public async Task CompileAsync(string sourceCode, string workingDirectory, string? version = null) + { + var sourceFilePath = Path.Combine(workingDirectory, "Solution.java"); + var classFilePath = Path.Combine(workingDirectory, "Solution.class"); + + _logger.LogInformation("Compiling Java code in {WorkingDirectory} with version {Version}", workingDirectory, version ?? "latest"); + + try + { + await File.WriteAllTextAsync(sourceFilePath, sourceCode); + + // Resolve version-specific configuration + var (compiler, compilerFlags) = ResolveVersion(version); + + _logger.LogDebug("Using compiler: {Compiler} with flags: {Flags}", compiler, compilerFlags); + + var arguments = string.IsNullOrWhiteSpace(compilerFlags) + ? $"\"{sourceFilePath}\"" + : $"{compilerFlags} \"{sourceFilePath}\""; + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = compiler, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + var compilerOutput = $"{output}\n{error}".Trim(); + + if (process.ExitCode == 0 && File.Exists(classFilePath)) + { + _logger.LogInformation("Java compilation successful"); + return new CompilationResult + { + Success = true, + ExecutablePath = classFilePath, + CompilerOutput = compilerOutput + }; + } + else + { + _logger.LogWarning("Java compilation failed with exit code {ExitCode}", process.ExitCode); + return new CompilationResult + { + Success = false, + ErrorMessage = "Compilation failed", + CompilerOutput = compilerOutput + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during Java compilation"); + return new CompilationResult + { + Success = false, + ErrorMessage = $"Compilation error: {ex.Message}" + }; + } + } + + private (string compiler, string compilerFlags) ResolveVersion(string? version) + { + // If version is null or "latest", use default configuration + if (string.IsNullOrEmpty(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase)) + { + var compiler = _configuration["Java:Compiler"] ?? "javac"; + var compilerFlags = _configuration["Java:CompilerFlags"] ?? ""; + return (compiler, compilerFlags); + } + + // Try to find version-specific configuration + var versionKey = $"Java:Versions:{version}"; + var versionCompiler = _configuration[$"{versionKey}:Compiler"]; + var versionFlags = _configuration[$"{versionKey}:CompilerFlags"]; + + if (!string.IsNullOrEmpty(versionCompiler)) + { + _logger.LogInformation("Using Java version {Version} configuration", version); + return (versionCompiler, versionFlags ?? ""); + } + + // Version not found, use default and log warning + _logger.LogWarning("Java version {Version} not found in configuration, using default", version); + var defaultCompiler = _configuration["Java:Compiler"] ?? "javac"; + var defaultFlags = _configuration["Java:CompilerFlags"] ?? ""; + return (defaultCompiler, defaultFlags); + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/JavaExecutionService.cs b/src/LiquidCode.Tester.Worker/Services/JavaExecutionService.cs new file mode 100644 index 0000000..5c6786a --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/JavaExecutionService.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; + +namespace LiquidCode.Tester.Worker.Services; + +public class JavaExecutionService : IExecutionService +{ + private readonly ILogger _logger; + + public JavaExecutionService(ILogger logger) + { + _logger = logger; + } + + public async Task ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb) + { + var workingDirectory = Path.GetDirectoryName(executablePath)!; + _logger.LogInformation("Executing Java class in {WorkingDirectory} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB", + workingDirectory, inputFilePath, timeLimitMs, memoryLimitMb); + + var result = new ExecutionResult(); + var stopwatch = Stopwatch.StartNew(); + + try + { + using var inputStream = File.OpenRead(inputFilePath); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "java", + Arguments = "-cp . Solution", + WorkingDirectory = workingDirectory, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + await inputStream.CopyToAsync(process.StandardInput.BaseStream); + process.StandardInput.Close(); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs)); + + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + + if (!completedInTime) + { + try + { + process.Kill(entireProcessTree: true); + } + catch { } + + result.TimeLimitExceeded = true; + result.ErrorMessage = "Time limit exceeded"; + _logger.LogWarning("Execution exceeded time limit"); + return result; + } + + result.Output = await outputTask; + result.ErrorOutput = await errorTask; + result.ExitCode = process.ExitCode; + + if (process.ExitCode != 0) + { + result.RuntimeError = true; + result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})"; + _logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode); + return result; + } + + try + { + result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024); + if (result.MemoryUsedMb > memoryLimitMb) + { + result.MemoryLimitExceeded = true; + result.ErrorMessage = "Memory limit exceeded"; + _logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb); + return result; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not measure memory usage"); + } + + result.Success = true; + _logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs); + } + catch (Exception ex) + { + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + result.RuntimeError = true; + result.ErrorMessage = $"Execution error: {ex.Message}"; + _logger.LogError(ex, "Error during execution"); + } + + return result; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/KotlinCompilationService.cs b/src/LiquidCode.Tester.Worker/Services/KotlinCompilationService.cs new file mode 100644 index 0000000..ca1d1ef --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/KotlinCompilationService.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using LiquidCode.Tester.Worker.Models; + +namespace LiquidCode.Tester.Worker.Services; + +public class KotlinCompilationService : ICompilationService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public KotlinCompilationService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + public async Task CompileAsync(string sourceCode, string workingDirectory, string? version = null) + { + var sourceFilePath = Path.Combine(workingDirectory, "Solution.kt"); + var jarFilePath = Path.Combine(workingDirectory, "solution.jar"); + + _logger.LogInformation("Compiling Kotlin code in {WorkingDirectory} with version {Version}", workingDirectory, version ?? "latest"); + + try + { + await File.WriteAllTextAsync(sourceFilePath, sourceCode); + + // Resolve version-specific configuration + var (compiler, compilerFlags) = ResolveVersion(version); + + _logger.LogDebug("Using compiler: {Compiler} with flags: {Flags}", compiler, compilerFlags); + + var arguments = string.IsNullOrWhiteSpace(compilerFlags) + ? $"\"{sourceFilePath}\" -include-runtime -d \"{jarFilePath}\"" + : $"{compilerFlags} \"{sourceFilePath}\" -include-runtime -d \"{jarFilePath}\""; + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = compiler, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + var compilerOutput = $"{output}\n{error}".Trim(); + + if (process.ExitCode == 0 && File.Exists(jarFilePath)) + { + _logger.LogInformation("Kotlin compilation successful"); + return new CompilationResult + { + Success = true, + ExecutablePath = jarFilePath, + CompilerOutput = compilerOutput + }; + } + else + { + _logger.LogWarning("Kotlin compilation failed with exit code {ExitCode}", process.ExitCode); + return new CompilationResult + { + Success = false, + ErrorMessage = "Compilation failed", + CompilerOutput = compilerOutput + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during Kotlin compilation"); + return new CompilationResult + { + Success = false, + ErrorMessage = $"Compilation error: {ex.Message}" + }; + } + } + + private (string compiler, string compilerFlags) ResolveVersion(string? version) + { + // If version is null or "latest", use default configuration + if (string.IsNullOrEmpty(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase)) + { + var compiler = _configuration["Kotlin:Compiler"] ?? "kotlinc"; + var compilerFlags = _configuration["Kotlin:CompilerFlags"] ?? ""; + return (compiler, compilerFlags); + } + + // Try to find version-specific configuration + var versionKey = $"Kotlin:Versions:{version}"; + var versionCompiler = _configuration[$"{versionKey}:Compiler"]; + var versionFlags = _configuration[$"{versionKey}:CompilerFlags"]; + + if (!string.IsNullOrEmpty(versionCompiler)) + { + _logger.LogInformation("Using Kotlin version {Version} configuration", version); + return (versionCompiler, versionFlags ?? ""); + } + + // Version not found, use default and log warning + _logger.LogWarning("Kotlin version {Version} not found in configuration, using default", version); + var defaultCompiler = _configuration["Kotlin:Compiler"] ?? "kotlinc"; + var defaultFlags = _configuration["Kotlin:CompilerFlags"] ?? ""; + return (defaultCompiler, defaultFlags); + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/KotlinExecutionService.cs b/src/LiquidCode.Tester.Worker/Services/KotlinExecutionService.cs new file mode 100644 index 0000000..5571b01 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/KotlinExecutionService.cs @@ -0,0 +1,110 @@ +using System.Diagnostics; + +namespace LiquidCode.Tester.Worker.Services; + +public class KotlinExecutionService : IExecutionService +{ + private readonly ILogger _logger; + + public KotlinExecutionService(ILogger logger) + { + _logger = logger; + } + + public async Task ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb) + { + _logger.LogInformation("Executing Kotlin JAR {Executable} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB", + executablePath, inputFilePath, timeLimitMs, memoryLimitMb); + + var result = new ExecutionResult(); + var stopwatch = Stopwatch.StartNew(); + + try + { + using var inputStream = File.OpenRead(inputFilePath); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "java", + Arguments = $"-jar \"{executablePath}\"", + WorkingDirectory = Path.GetDirectoryName(executablePath), + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + await inputStream.CopyToAsync(process.StandardInput.BaseStream); + process.StandardInput.Close(); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs)); + + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + + if (!completedInTime) + { + try + { + process.Kill(entireProcessTree: true); + } + catch { } + + result.TimeLimitExceeded = true; + result.ErrorMessage = "Time limit exceeded"; + _logger.LogWarning("Execution exceeded time limit"); + return result; + } + + result.Output = await outputTask; + result.ErrorOutput = await errorTask; + result.ExitCode = process.ExitCode; + + if (process.ExitCode != 0) + { + result.RuntimeError = true; + result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})"; + _logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode); + return result; + } + + try + { + result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024); + if (result.MemoryUsedMb > memoryLimitMb) + { + result.MemoryLimitExceeded = true; + result.ErrorMessage = "Memory limit exceeded"; + _logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb); + return result; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not measure memory usage"); + } + + result.Success = true; + _logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs); + } + catch (Exception ex) + { + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + result.RuntimeError = true; + result.ErrorMessage = $"Execution error: {ex.Message}"; + _logger.LogError(ex, "Error during execution"); + } + + return result; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/PythonCompilationService.cs b/src/LiquidCode.Tester.Worker/Services/PythonCompilationService.cs new file mode 100644 index 0000000..8d72730 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/PythonCompilationService.cs @@ -0,0 +1,74 @@ +using LiquidCode.Tester.Worker.Models; + +namespace LiquidCode.Tester.Worker.Services; + +public class PythonCompilationService : ICompilationService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public PythonCompilationService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + public async Task CompileAsync(string sourceCode, string workingDirectory, string? version = null) + { + var sourceFilePath = Path.Combine(workingDirectory, "solution.py"); + + _logger.LogInformation("Preparing Python code in {WorkingDirectory} with version {Version}", workingDirectory, version ?? "latest"); + + try + { + await File.WriteAllTextAsync(sourceFilePath, sourceCode); + + // Resolve version-specific executable (for execution service) + var executable = ResolveVersion(version); + + _logger.LogDebug("Using Python executable: {Executable}", executable); + _logger.LogInformation("Python code prepared successfully (no compilation needed)"); + + return new CompilationResult + { + Success = true, + ExecutablePath = sourceFilePath, + CompilerOutput = $"Python is an interpreted language - no compilation needed (using {executable})" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error preparing Python code"); + return new CompilationResult + { + Success = false, + ErrorMessage = $"Error preparing code: {ex.Message}" + }; + } + } + + private string ResolveVersion(string? version) + { + // If version is null or "latest", use default configuration + if (string.IsNullOrEmpty(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase)) + { + var executable = _configuration["Python:Executable"] ?? "python3"; + return executable; + } + + // Try to find version-specific configuration + var versionKey = $"Python:Versions:{version}"; + var versionExecutable = _configuration[$"{versionKey}:Executable"]; + + if (!string.IsNullOrEmpty(versionExecutable)) + { + _logger.LogInformation("Using Python version {Version} configuration", version); + return versionExecutable; + } + + // Version not found, use default and log warning + _logger.LogWarning("Python version {Version} not found in configuration, using default", version); + var defaultExecutable = _configuration["Python:Executable"] ?? "python3"; + return defaultExecutable; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/PythonExecutionService.cs b/src/LiquidCode.Tester.Worker/Services/PythonExecutionService.cs new file mode 100644 index 0000000..ed5dbd0 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/PythonExecutionService.cs @@ -0,0 +1,114 @@ +using System.Diagnostics; + +namespace LiquidCode.Tester.Worker.Services; + +public class PythonExecutionService : IExecutionService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public PythonExecutionService(ILogger logger, IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + public async Task ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb) + { + _logger.LogInformation("Executing Python script {Executable} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB", + executablePath, inputFilePath, timeLimitMs, memoryLimitMb); + + var result = new ExecutionResult(); + var stopwatch = Stopwatch.StartNew(); + + try + { + using var inputStream = File.OpenRead(inputFilePath); + + var pythonExecutable = _configuration["Python:Executable"] ?? "python3"; + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = pythonExecutable, + Arguments = $"\"{executablePath}\"", + WorkingDirectory = Path.GetDirectoryName(executablePath), + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + await inputStream.CopyToAsync(process.StandardInput.BaseStream); + process.StandardInput.Close(); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs)); + + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + + if (!completedInTime) + { + try + { + process.Kill(entireProcessTree: true); + } + catch { } + + result.TimeLimitExceeded = true; + result.ErrorMessage = "Time limit exceeded"; + _logger.LogWarning("Execution exceeded time limit"); + return result; + } + + result.Output = await outputTask; + result.ErrorOutput = await errorTask; + result.ExitCode = process.ExitCode; + + if (process.ExitCode != 0) + { + result.RuntimeError = true; + result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})"; + _logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode); + return result; + } + + try + { + result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024); + if (result.MemoryUsedMb > memoryLimitMb) + { + result.MemoryLimitExceeded = true; + result.ErrorMessage = "Memory limit exceeded"; + _logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb); + return result; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not measure memory usage"); + } + + result.Success = true; + _logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs); + } + catch (Exception ex) + { + stopwatch.Stop(); + result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds; + result.RuntimeError = true; + result.ErrorMessage = $"Execution error: {ex.Message}"; + _logger.LogError(ex, "Error during execution"); + } + + return result; + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/TestingService.cs b/src/LiquidCode.Tester.Worker/Services/TestingService.cs index 12a041a..061a17a 100644 --- a/src/LiquidCode.Tester.Worker/Services/TestingService.cs +++ b/src/LiquidCode.Tester.Worker/Services/TestingService.cs @@ -6,23 +6,23 @@ namespace LiquidCode.Tester.Worker.Services; public class TestingService : ITestingService { private readonly IPackageParserService _packageParser; - private readonly ICompilationService _compilationService; - private readonly IExecutionService _executionService; + private readonly ICompilationServiceFactory _compilationServiceFactory; + private readonly IExecutionServiceFactory _executionServiceFactory; private readonly IOutputCheckerService _outputChecker; private readonly ICallbackService _callbackService; private readonly ILogger _logger; public TestingService( IPackageParserService packageParser, - ICompilationService compilationService, - IExecutionService executionService, + ICompilationServiceFactory compilationServiceFactory, + IExecutionServiceFactory executionServiceFactory, IOutputCheckerService outputChecker, ICallbackService callbackService, ILogger logger) { _packageParser = packageParser; - _compilationService = compilationService; - _executionService = executionService; + _compilationServiceFactory = compilationServiceFactory; + _executionServiceFactory = executionServiceFactory; _outputChecker = outputChecker; _callbackService = callbackService; _logger = logger; @@ -39,8 +39,15 @@ public class TestingService : ITestingService // Parse package ProblemPackage package; - if (request.Package != null) + if (!string.IsNullOrEmpty(request.PackageFilePath)) { + // Use saved file path (from background task) + await using var fileStream = File.OpenRead(request.PackageFilePath); + package = await _packageParser.ParsePackageAsync(fileStream); + } + else if (request.Package != null) + { + // Use IFormFile directly (should not happen in background tasks) using var packageStream = request.Package.OpenReadStream(); package = await _packageParser.ParsePackageAsync(packageStream); } @@ -54,8 +61,12 @@ public class TestingService : ITestingService // Send compiling status await SendStatusAsync(request, State.Compiling, ErrorCode.None, "Compiling solution", 0, package.TestCases.Count); + // Get language-specific services + var compilationService = _compilationServiceFactory.GetCompilationService(request.Language); + var executionService = _executionServiceFactory.GetExecutionService(request.Language); + // Compile user solution - var compilationResult = await _compilationService.CompileAsync(request.SourceCode, package.WorkingDirectory); + var compilationResult = await compilationService.CompileAsync(request.SourceCode, package.WorkingDirectory, request.LanguageVersion); if (!compilationResult.Success) { @@ -80,7 +91,7 @@ public class TestingService : ITestingService $"Running test {testCase.Number}", testCase.Number, package.TestCases.Count); // Execute solution - var executionResult = await _executionService.ExecuteAsync( + var executionResult = await executionService.ExecuteAsync( compilationResult.ExecutablePath!, testCase.InputFilePath, testCase.TimeLimit, diff --git a/src/LiquidCode.Tester.Worker/appsettings.json b/src/LiquidCode.Tester.Worker/appsettings.json index c8f2770..7ec48f4 100644 --- a/src/LiquidCode.Tester.Worker/appsettings.json +++ b/src/LiquidCode.Tester.Worker/appsettings.json @@ -8,6 +8,83 @@ "AllowedHosts": "*", "Cpp": { "Compiler": "g++", - "CompilerFlags": "-O2 -std=c++17 -Wall" + "CompilerFlags": "-O2 -std=c++17 -Wall", + "Versions": { + "14": { + "Compiler": "g++", + "CompilerFlags": "-O2 -std=c++14 -Wall" + }, + "17": { + "Compiler": "g++", + "CompilerFlags": "-O2 -std=c++17 -Wall" + }, + "20": { + "Compiler": "g++", + "CompilerFlags": "-O2 -std=c++20 -Wall" + } + } + }, + "Java": { + "Compiler": "javac", + "CompilerFlags": "", + "Versions": { + "8": { + "Compiler": "javac", + "CompilerFlags": "-source 8 -target 8" + }, + "11": { + "Compiler": "javac", + "CompilerFlags": "-source 11 -target 11" + }, + "17": { + "Compiler": "javac", + "CompilerFlags": "" + } + } + }, + "Kotlin": { + "Compiler": "kotlinc", + "CompilerFlags": "", + "Versions": { + "1.9": { + "Compiler": "kotlinc", + "CompilerFlags": "" + } + } + }, + "CSharp": { + "Compiler": "csc", + "CompilerFlags": "/optimize+", + "Versions": { + "7": { + "Compiler": "csc", + "CompilerFlags": "/optimize+ /langversion:7" + }, + "8": { + "Compiler": "csc", + "CompilerFlags": "/optimize+ /langversion:8" + }, + "9": { + "Compiler": "csc", + "CompilerFlags": "/optimize+ /langversion:9" + } + } + }, + "Python": { + "Executable": "python3", + "Versions": { + "3.8": { + "Executable": "python3.8" + }, + "3.9": { + "Executable": "python3.9" + }, + "3.10": { + "Executable": "python3.10" + }, + "3.11": { + "Executable": "python3.11" + } + } } } diff --git a/tests/LiquidCode.Tester.Worker.Tests/CompilationServiceFactoryTests.cs b/tests/LiquidCode.Tester.Worker.Tests/CompilationServiceFactoryTests.cs new file mode 100644 index 0000000..42a4a2c --- /dev/null +++ b/tests/LiquidCode.Tester.Worker.Tests/CompilationServiceFactoryTests.cs @@ -0,0 +1,128 @@ +using LiquidCode.Tester.Worker.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace LiquidCode.Tester.Worker.Tests; + +public class CompilationServiceFactoryTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly CompilationServiceFactory _factory; + + public CompilationServiceFactoryTests() + { + var services = new ServiceCollection(); + + // Mock configuration + var configuration = new Mock(); + services.AddSingleton(configuration.Object); + + // Mock logger + var loggerFactory = new Mock(); + loggerFactory.Setup(x => x.CreateLogger(It.IsAny())) + .Returns(new Mock().Object); + services.AddSingleton(loggerFactory.Object); + services.AddLogging(); + + // Register compilation services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + _serviceProvider = services.BuildServiceProvider(); + _factory = new CompilationServiceFactory( + _serviceProvider, + _serviceProvider.GetRequiredService>()); + } + + [Theory] + [InlineData("C++", typeof(CppCompilationService))] + [InlineData("c++", typeof(CppCompilationService))] + [InlineData("cpp", typeof(CppCompilationService))] + [InlineData("CPP", typeof(CppCompilationService))] + public void GetCompilationService_CppLanguage_ReturnsCppCompilationService(string language, Type expectedType) + { + // Act + var service = _factory.GetCompilationService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Java", typeof(JavaCompilationService))] + [InlineData("java", typeof(JavaCompilationService))] + [InlineData("JAVA", typeof(JavaCompilationService))] + public void GetCompilationService_JavaLanguage_ReturnsJavaCompilationService(string language, Type expectedType) + { + // Act + var service = _factory.GetCompilationService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Kotlin", typeof(KotlinCompilationService))] + [InlineData("kotlin", typeof(KotlinCompilationService))] + [InlineData("KOTLIN", typeof(KotlinCompilationService))] + public void GetCompilationService_KotlinLanguage_ReturnsKotlinCompilationService(string language, Type expectedType) + { + // Act + var service = _factory.GetCompilationService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("C#", typeof(CSharpCompilationService))] + [InlineData("c#", typeof(CSharpCompilationService))] + [InlineData("csharp", typeof(CSharpCompilationService))] + [InlineData("CSharp", typeof(CSharpCompilationService))] + [InlineData("CSHARP", typeof(CSharpCompilationService))] + public void GetCompilationService_CSharpLanguage_ReturnsCSharpCompilationService(string language, Type expectedType) + { + // Act + var service = _factory.GetCompilationService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Python", typeof(PythonCompilationService))] + [InlineData("python", typeof(PythonCompilationService))] + [InlineData("PYTHON", typeof(PythonCompilationService))] + public void GetCompilationService_PythonLanguage_ReturnsPythonCompilationService(string language, Type expectedType) + { + // Act + var service = _factory.GetCompilationService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Go")] + [InlineData("Rust")] + [InlineData("JavaScript")] + [InlineData("")] + [InlineData(" ")] + public void GetCompilationService_UnsupportedLanguage_ThrowsNotSupportedException(string language) + { + // Act & Assert + Assert.Throws(() => _factory.GetCompilationService(language)); + } + + [Fact] + public void GetCompilationService_NullLanguage_ThrowsException() + { + // Act & Assert + Assert.Throws(() => _factory.GetCompilationService(null!)); + } +} diff --git a/tests/LiquidCode.Tester.Worker.Tests/ExecutionServiceFactoryTests.cs b/tests/LiquidCode.Tester.Worker.Tests/ExecutionServiceFactoryTests.cs new file mode 100644 index 0000000..7c59bef --- /dev/null +++ b/tests/LiquidCode.Tester.Worker.Tests/ExecutionServiceFactoryTests.cs @@ -0,0 +1,128 @@ +using LiquidCode.Tester.Worker.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace LiquidCode.Tester.Worker.Tests; + +public class ExecutionServiceFactoryTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ExecutionServiceFactory _factory; + + public ExecutionServiceFactoryTests() + { + var services = new ServiceCollection(); + + // Mock configuration + var configuration = new Mock(); + services.AddSingleton(configuration.Object); + + // Mock logger + var loggerFactory = new Mock(); + loggerFactory.Setup(x => x.CreateLogger(It.IsAny())) + .Returns(new Mock().Object); + services.AddSingleton(loggerFactory.Object); + services.AddLogging(); + + // Register execution services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + _serviceProvider = services.BuildServiceProvider(); + _factory = new ExecutionServiceFactory( + _serviceProvider, + _serviceProvider.GetRequiredService>()); + } + + [Theory] + [InlineData("C++", typeof(CppExecutionService))] + [InlineData("c++", typeof(CppExecutionService))] + [InlineData("cpp", typeof(CppExecutionService))] + [InlineData("CPP", typeof(CppExecutionService))] + public void GetExecutionService_CppLanguage_ReturnsCppExecutionService(string language, Type expectedType) + { + // Act + var service = _factory.GetExecutionService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Java", typeof(JavaExecutionService))] + [InlineData("java", typeof(JavaExecutionService))] + [InlineData("JAVA", typeof(JavaExecutionService))] + public void GetExecutionService_JavaLanguage_ReturnsJavaExecutionService(string language, Type expectedType) + { + // Act + var service = _factory.GetExecutionService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Kotlin", typeof(KotlinExecutionService))] + [InlineData("kotlin", typeof(KotlinExecutionService))] + [InlineData("KOTLIN", typeof(KotlinExecutionService))] + public void GetExecutionService_KotlinLanguage_ReturnsKotlinExecutionService(string language, Type expectedType) + { + // Act + var service = _factory.GetExecutionService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("C#", typeof(CSharpExecutionService))] + [InlineData("c#", typeof(CSharpExecutionService))] + [InlineData("csharp", typeof(CSharpExecutionService))] + [InlineData("CSharp", typeof(CSharpExecutionService))] + [InlineData("CSHARP", typeof(CSharpExecutionService))] + public void GetExecutionService_CSharpLanguage_ReturnsCSharpExecutionService(string language, Type expectedType) + { + // Act + var service = _factory.GetExecutionService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Python", typeof(PythonExecutionService))] + [InlineData("python", typeof(PythonExecutionService))] + [InlineData("PYTHON", typeof(PythonExecutionService))] + public void GetExecutionService_PythonLanguage_ReturnsPythonExecutionService(string language, Type expectedType) + { + // Act + var service = _factory.GetExecutionService(language); + + // Assert + Assert.IsType(expectedType, service); + } + + [Theory] + [InlineData("Go")] + [InlineData("Rust")] + [InlineData("JavaScript")] + [InlineData("")] + [InlineData(" ")] + public void GetExecutionService_UnsupportedLanguage_ThrowsNotSupportedException(string language) + { + // Act & Assert + Assert.Throws(() => _factory.GetExecutionService(language)); + } + + [Fact] + public void GetExecutionService_NullLanguage_ThrowsException() + { + // Act & Assert + Assert.Throws(() => _factory.GetExecutionService(null!)); + } +} diff --git a/tests/LiquidCode.Tester.Worker.Tests/LiquidCode.Tester.Worker.Tests.csproj b/tests/LiquidCode.Tester.Worker.Tests/LiquidCode.Tester.Worker.Tests.csproj new file mode 100644 index 0000000..0539338 --- /dev/null +++ b/tests/LiquidCode.Tester.Worker.Tests/LiquidCode.Tester.Worker.Tests.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs b/tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs new file mode 100644 index 0000000..b38c2a7 --- /dev/null +++ b/tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs @@ -0,0 +1,169 @@ +using LiquidCode.Tester.Worker.Services; +using Microsoft.Extensions.Logging; +using Moq; + +namespace LiquidCode.Tester.Worker.Tests; + +public class OutputCheckerServiceTests : IDisposable +{ + private readonly OutputCheckerService _service; + private readonly string _testDirectory; + + public OutputCheckerServiceTests() + { + var logger = new Mock>(); + _service = new OutputCheckerService(logger.Object); + _testDirectory = Path.Combine(Path.GetTempPath(), "OutputCheckerTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDirectory); + } + + [Fact] + public async Task CheckOutputAsync_ExactMatch_ReturnsTrue() + { + // Arrange + var expectedOutput = "Hello, World!"; + var actualOutput = "Hello, World!"; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckOutputAsync_DifferentOutput_ReturnsFalse() + { + // Arrange + var expectedOutput = "Hello, World!"; + var actualOutput = "Goodbye, World!"; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CheckOutputAsync_TrailingWhitespace_ReturnsTrue() + { + // Arrange + var expectedOutput = "Hello, World!"; + var actualOutput = "Hello, World! \n\n"; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckOutputAsync_LeadingWhitespace_ReturnsTrue() + { + // Arrange + var expectedOutput = "Hello, World!"; + var actualOutput = " \n\nHello, World!"; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckOutputAsync_DifferentLineEndings_ReturnsTrue() + { + // Arrange + var expectedOutput = "Line1\nLine2\nLine3"; + var actualOutput = "Line1\r\nLine2\r\nLine3"; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckOutputAsync_MultipleLines_ExactMatch_ReturnsTrue() + { + // Arrange + var expectedOutput = "Line 1\nLine 2\nLine 3"; + var actualOutput = "Line 1\nLine 2\nLine 3"; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckOutputAsync_EmptyOutputs_ReturnsTrue() + { + // Arrange + var expectedOutput = ""; + var actualOutput = ""; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckOutputAsync_OnlyWhitespace_ReturnsTrue() + { + // Arrange + var expectedOutput = " \n\n "; + var actualOutput = " \n\n "; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CheckOutputAsync_CaseSensitive_ReturnsFalse() + { + // Arrange + var expectedOutput = "Hello, World!"; + var actualOutput = "hello, world!"; + var expectedFilePath = CreateTempFile(expectedOutput); + + // Act + var result = await _service.CheckOutputAsync(actualOutput, expectedFilePath); + + // Assert + Assert.False(result); + } + + private string CreateTempFile(string content) + { + var filePath = Path.Combine(_testDirectory, $"test_{Guid.NewGuid()}.txt"); + File.WriteAllText(filePath, content); + return filePath; + } + + public void Dispose() + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, true); + } + } +} diff --git a/tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs b/tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs new file mode 100644 index 0000000..2f1e0f6 --- /dev/null +++ b/tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs @@ -0,0 +1,210 @@ +using System.IO.Compression; +using LiquidCode.Tester.Worker.Services; +using Microsoft.Extensions.Logging; +using Moq; + +namespace LiquidCode.Tester.Worker.Tests; + +public class PackageParserServiceTests : IDisposable +{ + private readonly PackageParserService _service; + private readonly string _testDirectory; + + public PackageParserServiceTests() + { + var logger = new Mock>(); + _service = new PackageParserService(logger.Object); + _testDirectory = Path.Combine(Path.GetTempPath(), "PackageParserTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDirectory); + } + + [Fact] + public async Task ParsePackageAsync_ValidPackageWithTestCases_ParsesSuccessfully() + { + // Arrange + var zipStream = CreateTestPackage(new[] + { + ("1.in", "input1"), + ("1.out", "output1"), + ("2.in", "input2"), + ("2.out", "output2"), + ("3.in", "input3"), + ("3.out", "output3") + }); + + // Act + var result = await _service.ParsePackageAsync(zipStream); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.TestCases.Count); + Assert.True(Directory.Exists(result.WorkingDirectory)); + + // Check test case 1 + Assert.Equal(1, result.TestCases[0].Number); + Assert.True(File.Exists(result.TestCases[0].InputFilePath)); + Assert.True(File.Exists(result.TestCases[0].OutputFilePath)); + Assert.Equal("input1", await File.ReadAllTextAsync(result.TestCases[0].InputFilePath)); + + // Check test case 2 + Assert.Equal(2, result.TestCases[1].Number); + Assert.True(File.Exists(result.TestCases[1].InputFilePath)); + Assert.True(File.Exists(result.TestCases[1].OutputFilePath)); + + // Check test case 3 + Assert.Equal(3, result.TestCases[2].Number); + Assert.True(File.Exists(result.TestCases[2].InputFilePath)); + Assert.True(File.Exists(result.TestCases[2].OutputFilePath)); + } + + [Fact] + public async Task ParsePackageAsync_PackageWithDefaultLimits_UsesDefaultValues() + { + // Arrange + var zipStream = CreateTestPackage(new[] + { + ("1.in", "input1"), + ("1.out", "output1") + }); + + // Act + var result = await _service.ParsePackageAsync(zipStream); + + // Assert + Assert.Single(result.TestCases); + Assert.Equal(2000, result.TestCases[0].TimeLimit); // Default time limit + Assert.Equal(256, result.TestCases[0].MemoryLimit); // Default memory limit + } + + [Fact] + public async Task ParsePackageAsync_EmptyPackage_ReturnsEmptyTestCasesList() + { + // Arrange + var zipStream = CreateTestPackage(Array.Empty<(string, string)>()); + + // Act + var result = await _service.ParsePackageAsync(zipStream); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.TestCases); + } + + [Fact] + public async Task ParsePackageAsync_TestCasesNotInOrder_SortsCorrectly() + { + // Arrange + var zipStream = CreateTestPackage(new[] + { + ("3.in", "input3"), + ("3.out", "output3"), + ("1.in", "input1"), + ("1.out", "output1"), + ("2.in", "input2"), + ("2.out", "output2") + }); + + // Act + var result = await _service.ParsePackageAsync(zipStream); + + // Assert + Assert.Equal(3, result.TestCases.Count); + Assert.Equal(1, result.TestCases[0].Number); + Assert.Equal(2, result.TestCases[1].Number); + Assert.Equal(3, result.TestCases[2].Number); + } + + [Fact] + public async Task ParsePackageAsync_MissingOutputFile_SkipsTestCase() + { + // Arrange + var zipStream = CreateTestPackage(new[] + { + ("1.in", "input1"), + ("1.out", "output1"), + ("2.in", "input2"), + // Missing 2.out + ("3.in", "input3"), + ("3.out", "output3") + }); + + // Act + var result = await _service.ParsePackageAsync(zipStream); + + // Assert + Assert.Equal(2, result.TestCases.Count); // Only test 1 and 3 + Assert.Equal(1, result.TestCases[0].Number); + Assert.Equal(3, result.TestCases[1].Number); + } + + [Fact] + public async Task ParsePackageAsync_MissingInputFile_SkipsTestCase() + { + // Arrange + var zipStream = CreateTestPackage(new[] + { + ("1.in", "input1"), + ("1.out", "output1"), + // Missing 2.in + ("2.out", "output2"), + ("3.in", "input3"), + ("3.out", "output3") + }); + + // Act + var result = await _service.ParsePackageAsync(zipStream); + + // Assert + Assert.Equal(2, result.TestCases.Count); // Only test 1 and 3 + Assert.Equal(1, result.TestCases[0].Number); + Assert.Equal(3, result.TestCases[1].Number); + } + + [Fact] + public async Task ParsePackageAsync_TestsInSubdirectory_ParsesSuccessfully() + { + // Arrange + var zipStream = CreateTestPackage(new[] + { + ("tests/1.in", "input1"), + ("tests/1.out", "output1"), + ("tests/2.in", "input2"), + ("tests/2.out", "output2") + }); + + // Act + var result = await _service.ParsePackageAsync(zipStream); + + // Assert + Assert.Equal(2, result.TestCases.Count); + Assert.Equal(1, result.TestCases[0].Number); + Assert.Equal(2, result.TestCases[1].Number); + } + + private MemoryStream CreateTestPackage(IEnumerable<(string fileName, string content)> files) + { + var memoryStream = new MemoryStream(); + + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + foreach (var (fileName, content) in files) + { + var entry = archive.CreateEntry(fileName); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write(content); + } + } + + memoryStream.Position = 0; + return memoryStream; + } + + public void Dispose() + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, true); + } + } +}