init
This commit is contained in:
44
deploy.sh
Normal file
44
deploy.sh
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# TestArena Deployment Script
|
||||||
|
# Run this script with sudo: sudo ./deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Starting TestArena Deployment..."
|
||||||
|
|
||||||
|
# 1. Install Dependencies
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nginx python3-pip python3-venv
|
||||||
|
|
||||||
|
# 2. Set up Python Virtual Environment
|
||||||
|
echo "🐍 Setting up Python environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install fastapi uvicorn sqlalchemy
|
||||||
|
|
||||||
|
# 3. Configure Nginx
|
||||||
|
echo "🌐 Configuring Nginx..."
|
||||||
|
cp nginx/testarena.conf /etc/nginx/sites-available/testarena
|
||||||
|
ln -sf /etc/nginx/sites-available/testarena /etc/nginx/sites-enabled/
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# 4. Create Data Directory
|
||||||
|
echo "📁 Creating data directory..."
|
||||||
|
mkdir -p /home/asf/testarena
|
||||||
|
chown -R asf:asf /home/asf/testarena
|
||||||
|
chmod -R 755 /home/asf/testarena
|
||||||
|
|
||||||
|
# 5. Restart Nginx
|
||||||
|
echo "🔄 Restarting Nginx..."
|
||||||
|
nginx -t
|
||||||
|
systemctl restart nginx
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo "Dashboard: http://asf-server.duckdns.org:8080/"
|
||||||
|
echo "Results: http://asf-server.duckdns.org:8080/results/"
|
||||||
|
echo "--------------------------------------------------"
|
||||||
|
echo "To start the app: source venv/bin/activate && uvicorn testarena_app.main:app --host 0.0.0.0 --port 8000"
|
||||||
|
echo "To start the worker: source venv/bin/activate && python3 -m testarena_app.worker"
|
||||||
80
deployment_guide.md
Normal file
80
deployment_guide.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# TestArena Deployment & Testing Guide
|
||||||
|
|
||||||
|
This guide explains how to deploy and test the TestArena backend application on your Ubuntu Server.
|
||||||
|
|
||||||
|
## 🚀 Deployment Steps
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
Ensure you have the code on your server in a directory like `/home/asf/testarena_pc_backend`.
|
||||||
|
|
||||||
|
### 2. Run the Deployment Script
|
||||||
|
The deployment script automates Nginx configuration and dependency installation.
|
||||||
|
```bash
|
||||||
|
sudo chmod +x deploy.sh
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Application Services
|
||||||
|
You should run these in the background or using a process manager like `pm2` or `systemd`.
|
||||||
|
|
||||||
|
**Start the API Server:**
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
uvicorn testarena_app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start the Background Worker:**
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 -m testarena_app.worker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing the System
|
||||||
|
|
||||||
|
### 1. Verify Dashboard Access
|
||||||
|
Open your browser and navigate to:
|
||||||
|
`http://asf-server.duckdns.org:8080/`
|
||||||
|
You should see the modern, colorful TestArena dashboard.
|
||||||
|
|
||||||
|
### 2. Verify Results Browsing
|
||||||
|
Navigate to:
|
||||||
|
`http://asf-server.duckdns.org:8080/results/`
|
||||||
|
You should see an automatic directory listing of `/home/asf/testarena/`.
|
||||||
|
|
||||||
|
### 3. Test the Queue API
|
||||||
|
Run the following `curl` command to queue a test task:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://asf-server.duckdns.org:8080/api/queue \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"test_queue_001": [
|
||||||
|
"staging",
|
||||||
|
{
|
||||||
|
"task_1": "/home/asf/scenarios/test1.py",
|
||||||
|
"task_2": "/home/asf/scenarios/test2.py"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verify Worker Execution
|
||||||
|
- Check the dashboard; you should see the new queue appear and its status change from `Waiting` to `Running` and then `Finished`.
|
||||||
|
- Check the filesystem:
|
||||||
|
```bash
|
||||||
|
ls -R /home/asf/testarena/test_queue_001
|
||||||
|
```
|
||||||
|
You should see `queue_status.json` and any results generated by `tpf_execution.py`.
|
||||||
|
|
||||||
|
### 5. Test Abortion
|
||||||
|
Queue another task and click the **Abort** button on the dashboard. Verify that the status changes to `Aborted` in both the dashboard and the `queue_status.json` file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Troubleshooting
|
||||||
|
|
||||||
|
- **Nginx Errors**: Check logs with `sudo tail -f /var/log/nginx/error.log`.
|
||||||
|
- **FastAPI Errors**: Check the terminal where `uvicorn` is running.
|
||||||
|
- **Permission Issues**: Ensure `/home/asf/testarena` is writable by the user running the app.
|
||||||
|
- **Port 8080 Blocked**: Ensure your firewall (ufw) allows traffic on port 8080: `sudo ufw allow 8080`.
|
||||||
124
gitea_repo_controller.sh
Normal file
124
gitea_repo_controller.sh
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 1. Configuration (UPDATE IF NEEDED)
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
GIT_USERNAME="asfautomation"
|
||||||
|
GIT_PASSWORD="asfautomation"
|
||||||
|
|
||||||
|
REPO_HOST="gitea.nabd-co.com"
|
||||||
|
REPO_PATH="ASF-Nabd/ASF-SH"
|
||||||
|
TARGET_DIR="TPF/Sensor_hub_repo"
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 2. URL Encoding Function
|
||||||
|
# ----------------------------------------
|
||||||
|
urlencode() {
|
||||||
|
perl -pe 's/([^a-zA-Z0-9_.-])/sprintf("%%%02x", ord($1))/ge'
|
||||||
|
}
|
||||||
|
|
||||||
|
ENCODED_USERNAME=$(printf '%s' "${GIT_USERNAME}" | urlencode)
|
||||||
|
ENCODED_PASSWORD=$(printf '%s' "${GIT_PASSWORD}" | urlencode)
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_URL="https://${ENCODED_USERNAME}:${ENCODED_PASSWORD}@${REPO_HOST}/${REPO_PATH}.git"
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 3. Command & Arguments
|
||||||
|
# ----------------------------------------
|
||||||
|
COMMAND="$1"
|
||||||
|
BRANCH_NAME="$2"
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 4. Functions
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
clone_repo() {
|
||||||
|
if [ -d "${TARGET_DIR}" ]; then
|
||||||
|
echo "ℹ️ Repository already exists. Skipping clone."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📥 Cloning repository..."
|
||||||
|
git clone "${AUTH_URL}" "${TARGET_DIR}"
|
||||||
|
echo "✅ Clone completed."
|
||||||
|
}
|
||||||
|
|
||||||
|
checkout_branch() {
|
||||||
|
if [ -z "${BRANCH_NAME}" ]; then
|
||||||
|
echo "❌ Branch name is required for checkout."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "${TARGET_DIR}" ]; then
|
||||||
|
echo "❌ Repository not found. Run clone first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${TARGET_DIR}"
|
||||||
|
|
||||||
|
echo "📦 Stashing local changes (including untracked)..."
|
||||||
|
git stash push -u -m "automation-stash-before-checkout" || true
|
||||||
|
|
||||||
|
echo "🔄 Fetching latest changes..."
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
echo "🌿 Checking out main branch..."
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
echo "⬇️ Pulling latest main..."
|
||||||
|
git pull "${AUTH_URL}" main
|
||||||
|
|
||||||
|
echo "🌿 Checking out target branch: ${BRANCH_NAME}"
|
||||||
|
if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
|
||||||
|
git checkout "${BRANCH_NAME}"
|
||||||
|
else
|
||||||
|
git checkout -b "${BRANCH_NAME}" "origin/${BRANCH_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⬆️ Rebasing '${BRANCH_NAME}' onto latest main..."
|
||||||
|
git rebase main
|
||||||
|
|
||||||
|
cd - >/dev/null
|
||||||
|
echo "✅ Checkout and rebase completed successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_repo() {
|
||||||
|
if [ -d "${TARGET_DIR}" ]; then
|
||||||
|
echo "🗑️ Deleting repository directory..."
|
||||||
|
rm -rf "${TARGET_DIR}"
|
||||||
|
echo "✅ Repository deleted."
|
||||||
|
else
|
||||||
|
echo "ℹ️ Repository directory does not exist."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------
|
||||||
|
# 5. Main Execution
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
|
case "${COMMAND}" in
|
||||||
|
clone)
|
||||||
|
clone_repo
|
||||||
|
;;
|
||||||
|
checkout)
|
||||||
|
checkout_branch
|
||||||
|
;;
|
||||||
|
delete)
|
||||||
|
delete_repo
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Invalid command."
|
||||||
|
echo "Usage:"
|
||||||
|
echo " $0 clone"
|
||||||
|
echo " $0 checkout <branch>"
|
||||||
|
echo " $0 delete"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "✔ Automation script finished successfully"
|
||||||
|
echo "----------------------------------------"
|
||||||
58
nginx/testarena.conf
Normal file
58
nginx/testarena.conf
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# TestArena Nginx Configuration
|
||||||
|
# This file should be placed in /etc/nginx/sites-available/testarena
|
||||||
|
# and symlinked to /etc/nginx/sites-enabled/testarena
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Security: Prevent directory traversal and restrict symlinks
|
||||||
|
disable_symlinks on;
|
||||||
|
|
||||||
|
# Root directory for the results (autoindex)
|
||||||
|
location /results/ {
|
||||||
|
alias /home/asf/testarena/;
|
||||||
|
|
||||||
|
# Enable autoindex with requested features
|
||||||
|
autoindex on;
|
||||||
|
autoindex_exact_size off; # Human-readable sizes
|
||||||
|
autoindex_localtime on; # Local time
|
||||||
|
|
||||||
|
# Read-only access
|
||||||
|
limit_except GET {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prevent execution of scripts
|
||||||
|
location ~* \.(php|pl|py|sh|cgi)$ {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy requests to the FastAPI application
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support (if needed in future)
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom error pages
|
||||||
|
error_page 404 /404.html;
|
||||||
|
location = /404.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
scenario_exe_parser.py
Normal file
103
scenario_exe_parser.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Get the directory of the current Python file
|
||||||
|
current_directory = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
COMPONENT_DIR = os.path.join(current_directory, "Sensor_hub_repo", "components")
|
||||||
|
|
||||||
|
def finalize_output(data_obj):
|
||||||
|
# Convert defaultdict to standard dict recursively
|
||||||
|
# This removes the <lambda> and <class 'list'> metadata
|
||||||
|
standard_dict = json.loads(json.dumps(data_obj))
|
||||||
|
|
||||||
|
# Print ONLY the JSON string to stdout
|
||||||
|
#print(json.dumps(standard_dict, indent=4))
|
||||||
|
return standard_dict
|
||||||
|
|
||||||
|
def parse_test_scenario(xml_file_path):
|
||||||
|
"""
|
||||||
|
Parses a test scenario XML file and extracts the configuration and all
|
||||||
|
test case IDs mapped to their execution commands.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xml_file_path (str): The path to the XML file to parse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary in the format:
|
||||||
|
{
|
||||||
|
'config': <config_value>,
|
||||||
|
'test_cases': {
|
||||||
|
<test_case_id>: <test_exec_command>,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Returns an empty dictionary on error.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(xml_file_path):
|
||||||
|
print(f"Error: File not found at '{xml_file_path}'")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Parse the XML file
|
||||||
|
tree = ET.parse(xml_file_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
except ET.ParseError as e:
|
||||||
|
print(f"Error: Failed to parse XML file. Details: {e}")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An unexpected error occurred during file parsing: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Initialize the final structured output
|
||||||
|
parsed_data = {
|
||||||
|
'config': '',
|
||||||
|
'test_cases': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Extract the mandatory <config> value
|
||||||
|
config_element = root.find('config')
|
||||||
|
if config_element is not None and config_element.text:
|
||||||
|
parsed_data['config'] = config_element.text.strip()
|
||||||
|
|
||||||
|
# 3. Iterate over all <test_case> elements and extract ID and Exec
|
||||||
|
for tc in root.findall('test_case'):
|
||||||
|
tc_id_element = tc.find('test_case_id')
|
||||||
|
tc_exec_element = tc.find('test_exec')
|
||||||
|
|
||||||
|
# Use strip() and check against None for safety, even if validation passed
|
||||||
|
tc_id = tc_id_element.text.strip() if tc_id_element is not None and tc_id_element.text else "UNKNOWN_ID"
|
||||||
|
tc_exec = tc_exec_element.text.strip() if tc_exec_element is not None and tc_exec_element.text else "UNKNOWN_EXEC"
|
||||||
|
|
||||||
|
# Add to the test_cases dictionary
|
||||||
|
parsed_data['test_cases'][tc_id] = tc_exec
|
||||||
|
|
||||||
|
return parsed_data
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Define a default path to test against
|
||||||
|
default_test_file = 'sample_scenario.xml'
|
||||||
|
|
||||||
|
# Allow passing the file path as a command-line argument for flexibility
|
||||||
|
file_to_check = sys.argv[1] if len(sys.argv) > 1 else print({})
|
||||||
|
file_path = os.path.join(COMPONENT_DIR, file_to_check)
|
||||||
|
|
||||||
|
print(f"--- XML Test Scenario Parser ---")
|
||||||
|
print(f"Parsing file: {file_to_check}\n")
|
||||||
|
|
||||||
|
# Run the parser
|
||||||
|
scenario_data = parse_test_scenario(file_path)
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
# if scenario_data:
|
||||||
|
# print("✅ Parsing Successful. Extracted Data Structure:")
|
||||||
|
# print(f"CONFIG: {scenario_data['config']}")
|
||||||
|
# print("\nTEST CASES:")
|
||||||
|
# for test_id, command in scenario_data['test_cases'].items():
|
||||||
|
# print(f" - {test_id}:\n '{command}'")
|
||||||
|
|
||||||
|
print(finalize_output(scenario_data))
|
||||||
|
#return finalize_output(scenario_data['test_cases'])
|
||||||
165
scenario_execution.py
Normal file
165
scenario_execution.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from scenario_exe_parser import parse_test_scenario
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
# Assuming parse_test_scenario is imported correctly
|
||||||
|
# from scenario_exe_parser import parse_test_scenario
|
||||||
|
|
||||||
|
# --- Global Paths ---
|
||||||
|
current_directory = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO_PATH = os.path.join(current_directory, "Sensor_hub_repo")
|
||||||
|
COMPONENT_DIR = os.path.join(REPO_PATH, "components")
|
||||||
|
RESULT_PATH = "/home/asf/testarena"
|
||||||
|
|
||||||
|
# The HTML Template
|
||||||
|
REPORT_TEMPLATE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>ESP32 Test Execution Report</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 40px; background-color: #f4f7f6; }
|
||||||
|
h2 { color: #333; border-bottom: 2px solid #ccc; padding-bottom: 10px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 20px 0; background-color: #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
|
||||||
|
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||||
|
th { background-color: #2c3e50; color: white; text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
.status-pass { color: #ffffff; background-color: #27ae60; padding: 4px 12px; border-radius: 4px; font-weight: bold; }
|
||||||
|
.status-fail { color: #ffffff; background-color: #c0392b; padding: 4px 12px; border-radius: 4px; font-weight: bold; }
|
||||||
|
a { color: #2980b9; text-decoration: none; font-weight: bold; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
tr:hover { background-color: #f1f1f1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Overall Scenario Summary</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Scenario Name</th>
|
||||||
|
<th>Final Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{{scenario_name}}</td>
|
||||||
|
<td><span class="{{overall_class}}">{{overall_status}}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Detailed Test Cases</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Test Case ID</th>
|
||||||
|
<th>Result</th>
|
||||||
|
<th>Execution Log</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{test_case_rows}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run_test_suite(tasks):
|
||||||
|
aggregated_results = {}
|
||||||
|
shell_script = "./TPF/test_execution.sh"
|
||||||
|
if os.name != 'nt':
|
||||||
|
subprocess.run(["chmod", "+x", shell_script])
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
print(f"--- Starting Task: {task['id']} ---")
|
||||||
|
result = subprocess.run(
|
||||||
|
[shell_script, task['id'], task['cmd'], task['path'], REPO_PATH],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
print(result.stdout)
|
||||||
|
|
||||||
|
json_found = False
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("FINAL_JSON_OUTPUT:"):
|
||||||
|
json_string = line.replace("FINAL_JSON_OUTPUT:", "").strip()
|
||||||
|
try:
|
||||||
|
task_json = json.loads(json_string)
|
||||||
|
aggregated_results.update(task_json)
|
||||||
|
json_found = True
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"!!! JSON Parsing Error: {e}")
|
||||||
|
|
||||||
|
if not json_found:
|
||||||
|
aggregated_results[task['id']] = ["ERROR", "N/A"]
|
||||||
|
return aggregated_results
|
||||||
|
|
||||||
|
def generate_html_report(scenario_name, results, output_path):
|
||||||
|
all_passed = all(info[0] == "PASS" for info in results.values())
|
||||||
|
overall_status = "PASS" if all_passed else "FAIL"
|
||||||
|
overall_class = "status-pass" if all_passed else "status-fail"
|
||||||
|
|
||||||
|
test_case_rows = ""
|
||||||
|
for tc_id, info in results.items():
|
||||||
|
status = info[0]
|
||||||
|
log_url = info[1]
|
||||||
|
status_class = "status-pass" if status == "PASS" else "status-fail"
|
||||||
|
|
||||||
|
test_case_rows += f"""
|
||||||
|
<tr>
|
||||||
|
<td>{tc_id}</td>
|
||||||
|
<td><span class="{status_class}">{status}</span></td>
|
||||||
|
<td><a href="{log_url}" target="_blank">View Log</a></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Use the global REPORT_TEMPLATE
|
||||||
|
report_content = REPORT_TEMPLATE.replace("{{scenario_name}}", scenario_name) \
|
||||||
|
.replace("{{overall_status}}", overall_status) \
|
||||||
|
.replace("{{overall_class}}", overall_class) \
|
||||||
|
.replace("{{test_case_rows}}", test_case_rows)
|
||||||
|
|
||||||
|
report_file = os.path.join(output_path, "execution_report.html")
|
||||||
|
with open(report_file, "w") as f:
|
||||||
|
f.write(report_content)
|
||||||
|
print(f"HTML Report generated at: {report_file}")
|
||||||
|
|
||||||
|
def save_summary(results, task_id_path):
|
||||||
|
json_path = os.path.join(task_id_path, "final_summary.json")
|
||||||
|
with open(json_path, "w") as f:
|
||||||
|
json.dump(results, f, indent=4)
|
||||||
|
print(f"\nFinal results saved to {json_path}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
queue_id = "1234"
|
||||||
|
scenario_path = "application_layer/business_stack/actuator_manager/test/actuator_manager_init_test.test_scenario.xml"
|
||||||
|
task_id = "56754"
|
||||||
|
|
||||||
|
# Path logic
|
||||||
|
queue_path = os.path.join(RESULT_PATH, queue_id)
|
||||||
|
task_id_path = os.path.join(queue_path, task_id) # Corrected pathing
|
||||||
|
|
||||||
|
os.makedirs(task_id_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Note: Ensure parse_test_scenario is defined or imported
|
||||||
|
scenario_data = parse_test_scenario(os.path.join(COMPONENT_DIR, scenario_path))
|
||||||
|
|
||||||
|
my_tasks = []
|
||||||
|
sub_tasks_data = scenario_data['test_cases']
|
||||||
|
for case_id, exec_cmd in sub_tasks_data.items():
|
||||||
|
my_tasks.append({
|
||||||
|
"id": case_id,
|
||||||
|
"cmd": exec_cmd,
|
||||||
|
"path": task_id_path
|
||||||
|
})
|
||||||
|
|
||||||
|
final_data = run_test_suite(my_tasks)
|
||||||
|
save_summary(final_data, task_id_path)
|
||||||
|
|
||||||
|
# Generate report INSIDE the task folder
|
||||||
|
generate_html_report(os.path.basename(scenario_path), final_data, task_id_path)
|
||||||
147
scenario_scan.py
Normal file
147
scenario_scan.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
# Get the directory of the current Python file
|
||||||
|
current_directory = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
repo_root = Path(current_directory).parents[1]
|
||||||
|
COMPONENT_DIR = os.path.join(repo_root, "components")
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_output(data_obj):
|
||||||
|
# Convert defaultdict to standard dict recursively
|
||||||
|
# This removes the <lambda> and <class 'list'> metadata
|
||||||
|
standard_dict = json.loads(json.dumps(data_obj))
|
||||||
|
|
||||||
|
# Print ONLY the JSON string to stdout
|
||||||
|
#print(json.dumps(standard_dict, indent=4))
|
||||||
|
return standard_dict
|
||||||
|
|
||||||
|
def find_test_scenarios(root_dir):
|
||||||
|
"""
|
||||||
|
Recursively searches the given root directory for files ending with
|
||||||
|
'.test_scenario.xml' and returns a dictionary mapping scenario names to their
|
||||||
|
paths relative to the root directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root_dir (str): The absolute path to the starting directory (e.g., 'COMPONENTS').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, str]: A dictionary mapping scenario names (without suffix) to
|
||||||
|
their relative file paths.
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(root_dir):
|
||||||
|
print(f"Error: Directory not found or not accessible: {root_dir}")
|
||||||
|
return {} # Return empty dictionary
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
print(f"Scanning directory: '{root_dir}'...")
|
||||||
|
|
||||||
|
scenario_suffix = ".test_scenario.xml"
|
||||||
|
|
||||||
|
# Dictionary comprehension: {scenario_name: relative_path}
|
||||||
|
scenarios_map = {
|
||||||
|
# Key: Scenario name (filename without suffix)
|
||||||
|
filename.replace(scenario_suffix, ""):
|
||||||
|
# Value: Relative path
|
||||||
|
os.path.relpath(os.path.join(dirpath, filename), root_dir)
|
||||||
|
|
||||||
|
for dirpath, _, filenames in os.walk(root_dir)
|
||||||
|
for filename in filenames if filename.endswith(scenario_suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scenarios_map
|
||||||
|
|
||||||
|
def organize_by_layer_component(scenarios_map):
|
||||||
|
"""
|
||||||
|
Organizes scenario paths into a nested dictionary structure based on the file path:
|
||||||
|
{Layer_Folder: {Component_Folder: [scenario_name, ...]}}
|
||||||
|
|
||||||
|
It assumes the Layer is the first folder and the Component is the folder
|
||||||
|
preceding the 'test' directory (i.e., the third-to-last segment).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scenarios_map (dict[str, str]): Dictionary mapping scenario names to their
|
||||||
|
relative file paths.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
defaultdict: Nested dictionary (Layer -> Component -> List of Scenario Names).
|
||||||
|
"""
|
||||||
|
organized_data = defaultdict(lambda: defaultdict(list))
|
||||||
|
|
||||||
|
# Iterate over the scenario name and path
|
||||||
|
for scenario_name, path in scenarios_map.items():
|
||||||
|
# Split path into segments using the OS separator
|
||||||
|
segments = path.split(os.sep)
|
||||||
|
|
||||||
|
# Layer is the first segment (e.g., 'application_layer', 'drivers')
|
||||||
|
layer = segments[0]
|
||||||
|
|
||||||
|
# Component is the third-to-last segment (e.g., 'actuator_manager', 'ammonia')
|
||||||
|
# We assume the file is inside a 'test' folder inside a component folder.
|
||||||
|
if len(segments) >= 3:
|
||||||
|
component = segments[-3]
|
||||||
|
else:
|
||||||
|
# Fallback for scenarios found too close to the root
|
||||||
|
component = "Root_Component"
|
||||||
|
|
||||||
|
# Populate the nested dictionary
|
||||||
|
organized_data[layer][component].append(scenario_name)
|
||||||
|
|
||||||
|
return organized_data
|
||||||
|
|
||||||
|
def scenario_scan(components_root_dir):
|
||||||
|
"""
|
||||||
|
Main function to scan for test scenarios, print the organized structure, and
|
||||||
|
return the resulting dictionaries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[defaultdict, dict]: The organized layer/component structure and the
|
||||||
|
raw dictionary of scenario names to paths.
|
||||||
|
"""
|
||||||
|
# 1. Find all relative paths (now a dictionary: {name: path})
|
||||||
|
found_scenarios_map = find_test_scenarios(components_root_dir)
|
||||||
|
|
||||||
|
if not found_scenarios_map:
|
||||||
|
print(f"\nNo files ending with '.test_scenario.xml' were found in {components_root_dir}.")
|
||||||
|
# Return empty structures if nothing is found
|
||||||
|
return defaultdict(lambda: defaultdict(list)), {}
|
||||||
|
|
||||||
|
num_scenarios = len(found_scenarios_map)
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
# 2. Print the simple list of found paths
|
||||||
|
print(f"\n--- Found {num_scenarios} Test Scenarios ---")
|
||||||
|
for scenario_name, path in found_scenarios_map.items():
|
||||||
|
print(f"Scenario: '{scenario_name}' | Relative Path: {os.path.join("components",path)}")
|
||||||
|
|
||||||
|
# 3. Organize into the layer/component structure
|
||||||
|
organized_scenarios = organize_by_layer_component(found_scenarios_map)
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
# 4. Print the organized structure
|
||||||
|
print("\n--- Organized Layer/Component Structure ---")
|
||||||
|
for layer, components in organized_scenarios.items():
|
||||||
|
print(f"\n[LAYER] {layer.upper()}:")
|
||||||
|
for component, scenarios in components.items():
|
||||||
|
scenario_list = ", ".join(scenarios)
|
||||||
|
print(f" [Component] {component}: {scenario_list}")
|
||||||
|
|
||||||
|
return organized_scenarios, found_scenarios_map
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# The return value from scenario_scan now includes the dictionary you requested
|
||||||
|
organized_data, scenario_map = scenario_scan(COMPONENT_DIR)
|
||||||
|
combined_result = {
|
||||||
|
"organized_data": finalize_output(organized_data),
|
||||||
|
"scenario_map": finalize_output(scenario_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Print the combined object as a single JSON string
|
||||||
|
# This is what will be captured by the SSH command
|
||||||
|
print(json.dumps(combined_result))
|
||||||
|
|
||||||
60
test_execution.sh
Normal file
60
test_execution.sh
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Check if correct number of arguments are provided (now 4)
|
||||||
|
if [ "$#" -ne 4 ]; then
|
||||||
|
echo "Usage: $0 <task_id> <command> <result_dir> <repo_path>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TASK_ID=$1
|
||||||
|
CMD=$2
|
||||||
|
RESULT_DIR=$3
|
||||||
|
REPO_PATH=$4
|
||||||
|
echo $TASK_ID
|
||||||
|
# Create result directory if it doesn't exist (absolute path)
|
||||||
|
mkdir -p "$RESULT_DIR"
|
||||||
|
# Use realpath on the (now-existing) result dir and a clearer filename
|
||||||
|
LOG_FILE="$(realpath "$RESULT_DIR")/${TASK_ID}-logging.html"
|
||||||
|
|
||||||
|
# Initialize HTML file with basic styling
|
||||||
|
cat <<EOF > "$LOG_FILE"
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: monospace; background-color: #1e1e1e; color: #d4d4d4; padding: 20px; }
|
||||||
|
.cmd { color: #569cd6; font-weight: bold; }
|
||||||
|
.repo { color: #ce9178; }
|
||||||
|
.output { white-space: pre-wrap; display: block; margin-top: 10px; border-left: 3px solid #666; padding-left: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Execution Log for Task: $TASK_ID</h2>
|
||||||
|
<p class="repo">Working Directory: $REPO_PATH</p>
|
||||||
|
<p class="cmd">Executing: $CMD</p>
|
||||||
|
<hr>
|
||||||
|
<div class="output">
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 1. CD into the repo path
|
||||||
|
# 2. Execute command and capture output
|
||||||
|
# 3. PIPESTATUS[1] captures the exit code of the CMD, not the 'cd' or 'tee'
|
||||||
|
cd "$REPO_PATH" && eval "$CMD" 2>&1 | tee -a >(sed 's/$/<br>/' >> "$LOG_FILE")
|
||||||
|
EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
# Close HTML tags
|
||||||
|
echo "</div></body></html>" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Determine PASS/FAIL
|
||||||
|
if [ $EXIT_CODE -eq 0 ]; then
|
||||||
|
RESULT="PASS"
|
||||||
|
else
|
||||||
|
RESULT="FAIL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
EVIDENCE_URL="file://$LOG_FILE"
|
||||||
|
|
||||||
|
# Return JSON output
|
||||||
|
# ... (rest of the script remains the same)
|
||||||
|
|
||||||
|
# Return JSON output with a unique marker prefix
|
||||||
|
printf 'FINAL_JSON_OUTPUT:{"%s": ["%s", "%s"]}\n' "$TASK_ID" "$RESULT" "$EVIDENCE_URL"
|
||||||
16
testarena_app/database.py
Normal file
16
testarena_app/database.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import create_all_engines, create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Using SQLite for simplicity as requested
|
||||||
|
DATABASE_URL = "sqlite:///d:/ASF - course/ASF_01/ASF_tools/asf-pc-server/testarena_pc_backend/testarena_app/testarena.db"
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
139
testarena_app/main.py
Normal file
139
testarena_app/main.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, List
|
||||||
|
from . import models, database
|
||||||
|
|
||||||
|
app = FastAPI(title="TestArena API")
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
os.makedirs(static_dir, exist_ok=True)
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
|
# Base directory for data as requested
|
||||||
|
BASE_DATA_DIR = "/home/asf/testarena"
|
||||||
|
# For local development on Windows, we might need to adjust this,
|
||||||
|
# but I'll stick to the user's requirement for the final version.
|
||||||
|
if os.name == 'nt':
|
||||||
|
BASE_DATA_DIR = "d:/ASF - course/ASF_01/ASF_tools/asf-pc-server/testarena_pc_backend/testarena_data"
|
||||||
|
|
||||||
|
# Ensure base directory exists
|
||||||
|
os.makedirs(BASE_DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
models.Base.metadata.create_all(bind=database.engine)
|
||||||
|
|
||||||
|
@app.post("/api/queue")
|
||||||
|
async def queue_task(payload: Dict, db: Session = Depends(database.get_db)):
|
||||||
|
"""
|
||||||
|
Input json contain {<queue_ID> :[environment, "<TASK_ID>" : "<path to scenario>],}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
queue_id = list(payload.keys())[0]
|
||||||
|
data = payload[queue_id]
|
||||||
|
environment = data[0]
|
||||||
|
tasks_data = data[1] # This is a dict {"TASK_ID": "path"}
|
||||||
|
|
||||||
|
# 1. Create folder
|
||||||
|
queue_dir = os.path.join(BASE_DATA_DIR, queue_id)
|
||||||
|
os.makedirs(queue_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 2. Create queue_status.json
|
||||||
|
status_file = os.path.join(queue_dir, "queue_status.json")
|
||||||
|
queue_status = {
|
||||||
|
"queue_id": queue_id,
|
||||||
|
"status": "Waiting",
|
||||||
|
"tasks": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Save to database and prepare status file
|
||||||
|
new_queue = models.Queue(id=queue_id, environment=environment, status="Waiting")
|
||||||
|
db.add(new_queue)
|
||||||
|
|
||||||
|
for task_id, scenario_path in tasks_data.items():
|
||||||
|
new_task = models.Task(id=task_id, queue_id=queue_id, scenario_path=scenario_path, status="Waiting")
|
||||||
|
db.add(new_task)
|
||||||
|
queue_status["tasks"][task_id] = "Waiting"
|
||||||
|
|
||||||
|
with open(status_file, 'w') as f:
|
||||||
|
json.dump(queue_status, f, indent=4)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "Queue OK", "queue_id": queue_id}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "Error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.get("/api/status/{id}")
|
||||||
|
async def get_status(id: str, db: Session = Depends(database.get_db)):
|
||||||
|
# Check if it's a queue ID
|
||||||
|
queue = db.query(models.Queue).filter(models.Queue.id == id).first()
|
||||||
|
if queue:
|
||||||
|
return {"id": id, "type": "queue", "status": queue.status}
|
||||||
|
|
||||||
|
# Check if it's a task ID
|
||||||
|
task = db.query(models.Task).filter(models.Task.id == id).first()
|
||||||
|
if task:
|
||||||
|
return {"id": id, "type": "task", "status": task.status}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="ID not found")
|
||||||
|
|
||||||
|
@app.post("/api/abort/{id}")
|
||||||
|
async def abort_task(id: str, db: Session = Depends(database.get_db)):
|
||||||
|
# Abort queue
|
||||||
|
queue = db.query(models.Queue).filter(models.Queue.id == id).first()
|
||||||
|
if queue:
|
||||||
|
queue.status = "Aborted"
|
||||||
|
# Abort all tasks in queue
|
||||||
|
tasks = db.query(models.Task).filter(models.Task.queue_id == id).all()
|
||||||
|
for t in tasks:
|
||||||
|
if t.status in ["Waiting", "Running"]:
|
||||||
|
t.status = "Aborted"
|
||||||
|
|
||||||
|
# Update queue_status.json
|
||||||
|
queue_dir = os.path.join(BASE_DATA_DIR, id)
|
||||||
|
status_file = os.path.join(queue_dir, "queue_status.json")
|
||||||
|
if os.path.exists(status_file):
|
||||||
|
with open(status_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
data["status"] = "Aborted"
|
||||||
|
for tid in data["tasks"]:
|
||||||
|
if data["tasks"][tid] in ["Waiting", "Running"]:
|
||||||
|
data["tasks"][tid] = "Aborted"
|
||||||
|
with open(status_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"id": id, "status": "Aborted"}
|
||||||
|
|
||||||
|
# Abort single task
|
||||||
|
task = db.query(models.Task).filter(models.Task.id == id).first()
|
||||||
|
if task:
|
||||||
|
task.status = "Aborted"
|
||||||
|
# Update queue_status.json
|
||||||
|
queue_dir = os.path.join(BASE_DATA_DIR, task.queue_id)
|
||||||
|
status_file = os.path.join(queue_dir, "queue_status.json")
|
||||||
|
if os.path.exists(status_file):
|
||||||
|
with open(status_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
data["tasks"][id] = "Aborted"
|
||||||
|
with open(status_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"id": id, "status": "Aborted"}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="ID not found")
|
||||||
|
|
||||||
|
@app.get("/api/queues")
|
||||||
|
async def list_queues(db: Session = Depends(database.get_db)):
|
||||||
|
queues = db.query(models.Queue).order_by(models.Queue.created_at.desc()).all()
|
||||||
|
return queues
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return FileResponse(os.path.join(static_dir, "index.html"))
|
||||||
27
testarena_app/models.py
Normal file
27
testarena_app/models.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class Queue(Base):
|
||||||
|
__tablename__ = "queues"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
status = Column(String, default="Waiting") # Finished, Waiting, Running, Aborted
|
||||||
|
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
|
environment = Column(String)
|
||||||
|
|
||||||
|
tasks = relationship("Task", back_populates="queue", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class Task(Base):
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
queue_id = Column(String, ForeignKey("queues.id"))
|
||||||
|
scenario_path = Column(String)
|
||||||
|
status = Column(String, default="Waiting") # Finished, Waiting, Running, Aborted
|
||||||
|
result = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
queue = relationship("Queue", back_populates="tasks")
|
||||||
407
testarena_app/static/index.html
Normal file
407
testarena_app/static/index.html
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TestArena | Modern Dashboard</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-glow: rgba(99, 102, 241, 0.5);
|
||||||
|
--secondary: #ec4899;
|
||||||
|
--accent: #8b5cf6;
|
||||||
|
--bg: #0f172a;
|
||||||
|
--card-bg: rgba(30, 41, 59, 0.7);
|
||||||
|
--text: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--glass: rgba(255, 255, 255, 0.05);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
background: radial-gradient(circle at top right, #1e1b4b, #0f172a);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative blobs */
|
||||||
|
.blob {
|
||||||
|
position: absolute;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: var(--primary-glow);
|
||||||
|
filter: blur(100px);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: -1;
|
||||||
|
animation: move 20s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes move {
|
||||||
|
from {
|
||||||
|
transform: translate(-10%, -10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translate(20%, 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(to right, var(--primary), var(--secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--warning);
|
||||||
|
box-shadow: 0 0 10px var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.online {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 10px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
td:first-child {
|
||||||
|
border-radius: 1rem 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:last-child {
|
||||||
|
border-radius: 0 1rem 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-waiting {
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #818cf8;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-finished {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-aborted {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abort {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-abort:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
background: #020617;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: var(--primary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-msg {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--glass-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="blob"></div>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
TestArena
|
||||||
|
</div>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/results/" target="_blank">Browse Results</a>
|
||||||
|
</nav>
|
||||||
|
<div id="connection-status" class="status-badge">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<span>Connecting...</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9" />
|
||||||
|
<line x1="9" y1="21" x2="9" y2="9" />
|
||||||
|
</svg>
|
||||||
|
Queue Monitor
|
||||||
|
</h2>
|
||||||
|
<table id="queue-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Queue ID</th>
|
||||||
|
<th>Environment</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Dynamic content -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
Live System Logs
|
||||||
|
</h2>
|
||||||
|
<div id="logs" class="log-container">
|
||||||
|
<div class="log-entry">
|
||||||
|
<span class="log-time">23:34:52</span>
|
||||||
|
<span class="log-msg">System initialized. Waiting for connection...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/queues');
|
||||||
|
const queues = await response.json();
|
||||||
|
|
||||||
|
const tbody = document.querySelector('#queue-table tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
queues.forEach(q => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td style="font-weight: 600;">${q.id}</td>
|
||||||
|
<td><span style="opacity: 0.8;">${q.environment}</span></td>
|
||||||
|
<td><span class="status-pill status-${q.status.toLowerCase()}">${q.status}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-abort" onclick="abortQueue('${q.id}')">Abort</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = document.getElementById('connection-status');
|
||||||
|
badge.querySelector('.dot').classList.add('online');
|
||||||
|
badge.querySelector('span').textContent = 'System Online';
|
||||||
|
} catch (e) {
|
||||||
|
const badge = document.getElementById('connection-status');
|
||||||
|
badge.querySelector('.dot').classList.remove('online');
|
||||||
|
badge.querySelector('span').textContent = 'Connection Lost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abortQueue(id) {
|
||||||
|
if (confirm(`Are you sure you want to abort queue ${id}?`)) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/abort/${id}`, { method: 'POST' });
|
||||||
|
addLog(`Aborted queue: ${id}`, 'danger');
|
||||||
|
fetchStatus();
|
||||||
|
} catch (e) {
|
||||||
|
addLog(`Failed to abort queue: ${id}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLog(msg, type = 'info') {
|
||||||
|
const logs = document.getElementById('logs');
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'log-entry';
|
||||||
|
const time = new Date().toLocaleTimeString([], { hour12: false });
|
||||||
|
entry.innerHTML = `
|
||||||
|
<span class="log-time">${time}</span>
|
||||||
|
<span class="log-msg">${msg}</span>
|
||||||
|
`;
|
||||||
|
logs.appendChild(entry);
|
||||||
|
logs.scrollTop = logs.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch and poll
|
||||||
|
fetchStatus();
|
||||||
|
setInterval(fetchStatus, 3000);
|
||||||
|
|
||||||
|
// Simulate some system logs
|
||||||
|
setTimeout(() => addLog("Database connection established."), 1000);
|
||||||
|
setTimeout(() => addLog("Background worker is polling for tasks..."), 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
98
testarena_app/worker.py
Normal file
98
testarena_app/worker.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from . import models, database
|
||||||
|
|
||||||
|
# Base directory for data
|
||||||
|
BASE_DATA_DIR = "/home/asf/testarena"
|
||||||
|
if os.name == 'nt':
|
||||||
|
BASE_DATA_DIR = "d:/ASF - course/ASF_01/ASF_tools/asf-pc-server/testarena_pc_backend/testarena_data"
|
||||||
|
|
||||||
|
def update_json_status(queue_id, task_id, status, result=None):
|
||||||
|
queue_dir = os.path.join(BASE_DATA_DIR, queue_id)
|
||||||
|
status_file = os.path.join(queue_dir, "queue_status.json")
|
||||||
|
if os.path.exists(status_file):
|
||||||
|
with open(status_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
data["tasks"][task_id] = status
|
||||||
|
else:
|
||||||
|
data["status"] = status
|
||||||
|
|
||||||
|
if result:
|
||||||
|
data["results"] = data.get("results", {})
|
||||||
|
data["results"][task_id] = result
|
||||||
|
|
||||||
|
with open(status_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
|
||||||
|
def run_worker():
|
||||||
|
print("Worker started...")
|
||||||
|
while True:
|
||||||
|
db = database.SessionLocal()
|
||||||
|
try:
|
||||||
|
# Get next waiting queue
|
||||||
|
queue = db.query(models.Queue).filter(models.Queue.status == "Waiting").order_by(models.Queue.created_at).first()
|
||||||
|
|
||||||
|
if queue:
|
||||||
|
print(f"Processing queue: {queue.id}")
|
||||||
|
queue.status = "Running"
|
||||||
|
update_json_status(queue.id, None, "Running")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tasks = db.query(models.Task).filter(models.Task.queue_id == queue.id, models.Task.status == "Waiting").all()
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
# Check if queue was aborted mid-way
|
||||||
|
db.refresh(queue)
|
||||||
|
if queue.status == "Aborted":
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"Running task: {task.id}")
|
||||||
|
task.status = "Running"
|
||||||
|
update_json_status(queue.id, task.id, "Running")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run tpf_execution.py [queue_id, scenario_path, task_id]
|
||||||
|
# Assuming tpf_execution.py is in the parent directory or accessible
|
||||||
|
script_path = "tpf_execution.py"
|
||||||
|
# For testing, let's assume it's in the same dir as the app or parent
|
||||||
|
cmd = ["python", script_path, queue.id, task.scenario_path, task.id]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
# Parse result if it returns json
|
||||||
|
try:
|
||||||
|
execution_result = json.loads(result.stdout)
|
||||||
|
except:
|
||||||
|
execution_result = {"output": result.stdout, "error": result.stderr}
|
||||||
|
|
||||||
|
task.status = "Finished"
|
||||||
|
task.result = execution_result
|
||||||
|
update_json_status(queue.id, task.id, "Finished", execution_result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error running task {task.id}: {e}")
|
||||||
|
task.status = "Error"
|
||||||
|
update_json_status(queue.id, task.id, "Error")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if queue.status != "Aborted":
|
||||||
|
queue.status = "Finished"
|
||||||
|
update_json_status(queue.id, None, "Finished")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
time.sleep(5) # Poll every 5 seconds
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Worker error: {e}")
|
||||||
|
time.sleep(10)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_worker()
|
||||||
32
tpf_execution.py
Normal file
32
tpf_execution.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("Usage: python tpf_execution.py <queue_id> <scenario_path> <task_id>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
queue_id = sys.argv[1]
|
||||||
|
scenario_path = sys.argv[2]
|
||||||
|
task_id = sys.argv[3]
|
||||||
|
|
||||||
|
print(f"Starting execution for Task: {task_id} in Queue: {queue_id}")
|
||||||
|
print(f"Scenario: {scenario_path}")
|
||||||
|
|
||||||
|
# Simulate work
|
||||||
|
duration = random.randint(2, 5)
|
||||||
|
time.sleep(duration)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "Success",
|
||||||
|
"duration": duration,
|
||||||
|
"details": f"Scenario {scenario_path} executed successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user