hyper-wireless 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyper-wireless
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Hyper Wireless - Cloud Testing Platform for 3GPP 5G NR Conformance
|
|
5
|
+
Author-email: Hyper Wireless Team <dev@hyper-wireless.com>
|
|
6
|
+
Project-URL: Homepage, https://hyper-wireless.com
|
|
7
|
+
Project-URL: Dashboard, https://app.hyper-wireless.com
|
|
8
|
+
Project-URL: API, https://cloud-api.hyper-wireless.com
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Software Development :: Testing
|
|
11
|
+
Classifier: Topic :: System :: Networking
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.28.0
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
|
|
17
|
+
# TTCPP (TTCN-3 C++ Test Suite)
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
**TTCPP** is a C++ implementation of the 3GPP 5G test suite, originally written in TTCN-3. It is designed to test the 5G UE stack (specifically using `srsRAN_5G`'s `srsue` implementation). By transpiling the TTCN-3 test cases and components into C++, TTCPP offers a native, performant environment for 5G testing, completely bypassing the need for a traditional TTCN-3 compiler or heavy runtime environment.
|
|
22
|
+
|
|
23
|
+
The test suite acts as the network (AMF/gNB) and communicates with the Device Under Test (DUT), the `srsue` stack (packaged as `ttcn3_dut`), exchanging RRC, NAS, and IP packets to validate compliance against 3GPP specifications (such as TS 24.501 and TS 38.331).
|
|
24
|
+
|
|
25
|
+
## Key Features
|
|
26
|
+
|
|
27
|
+
- **Native C++ Execution:** The test cases are transpiled from TTCN-3 to modern C++ (`src/TTCN_TestSuite`), enabling standard debugging (GDB, LLDB) and high performance.
|
|
28
|
+
- **5G UE Testing:** Directly integrates with `srsRAN_5G` to validate UE behavior.
|
|
29
|
+
- **Custom Port Handlers:** C++ implementations for standard TTCN-3 port operations (handling sockets, threading, and system-level IPC) in `src/components/` and `src/common/`.
|
|
30
|
+
- **Message Sequence Charts (MSC):** Automatic parsing of test logs to render visual sequence diagrams of test execution.
|
|
31
|
+
- **Web GUI:** A modern Next.js-based graphical dashboard for managing, executing, and reviewing tests.
|
|
32
|
+
|
|
33
|
+
## Directory Structure
|
|
34
|
+
|
|
35
|
+
- `src/` - Source code for the transpiled test suite, custom components, codecs, and security algorithms.
|
|
36
|
+
- `src/suites/<version>/` - Isolated C++ transpiled test suite packages (e.g. `NR5GC_IWD_25wk50`).
|
|
37
|
+
- `src/TTCN_TestSuite/` - Symlink pointing to the active suite's transpiled modules.
|
|
38
|
+
- `src/components/` - TTCN-3 port and component handlers.
|
|
39
|
+
- `src/main.cpp` - The entry point for the `ttcn3_runner` test executor.
|
|
40
|
+
- `ttcn/` - Contains raw TTCN-3 packages organized by deliverable version (e.g. `ttcn/NR5GC_IWD_25wk50/`).
|
|
41
|
+
- `configs/` - Modular configuration repository:
|
|
42
|
+
- `configs/transpiler/` - Base transpiler configurations and transforms for `TTCN3-X`.
|
|
43
|
+
- `configs/suites/` - Per-suite transpiler overlay configurations.
|
|
44
|
+
- `configs/modulepars/` - Per-suite module parameters (PICS/PIXIT).
|
|
45
|
+
- `srsRAN_5G/` - The full-stack 5G RAN and UE source code repository, which compiles the `ttcn3_dut` UE simulator executable.
|
|
46
|
+
- `tools/` - Utility scripts and tools.
|
|
47
|
+
- `tools/suite_manager.py` - Test suite version manager (list, info, switch, transpile).
|
|
48
|
+
- `logs/` - Directory where test execution logs and artifacts are stored.
|
|
49
|
+
- `usim.cfg` - Configuration for the simulated USIM (Home PLMN, Equivalent PLMNs, etc.).
|
|
50
|
+
|
|
51
|
+
## Prerequisites & System Dependencies
|
|
52
|
+
|
|
53
|
+
To compile and run the test suite (specifically the virtual USIM smartcard integration), the following system dependencies and packages are required:
|
|
54
|
+
|
|
55
|
+
### 1. Host Packages
|
|
56
|
+
Install PC/SC daemon, PCSC-Lite development headers, and tools:
|
|
57
|
+
```bash
|
|
58
|
+
sudo apt-get install pcscd libpcsclite-dev pcsc-tools
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Virtual Smartcard Simulator (`onomondo-uicc`)
|
|
62
|
+
- Implements the softsim smartcard that simulates physical UICC/USIM behavior over PC/SC.
|
|
63
|
+
- Should be cloned and built in a sibling directory (e.g., `../onomondo-uicc` relative to this repository). Respository URL: https://github.com/onomondo/onomondo-uicc.git
|
|
64
|
+
- The test runner defaults to this sibling directory, but you can override it by exporting the `ONOMONDO_UICC_DIR` environment variable.
|
|
65
|
+
- Ensure it is compiled before running tests:
|
|
66
|
+
```bash
|
|
67
|
+
cd ../onomondo-uicc
|
|
68
|
+
make # or standard build commands for softsim
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 3. SIM Card Configuration Utility (`pySim`)
|
|
72
|
+
- Used by the test suite to configure the softsim smartcard (programming IMSIs, security parameters, resetting SQNs, etc.).
|
|
73
|
+
- Should be set up in a sibling directory (e.g., `../pysim` relative to this repository). Respository URL: https://github.com/osmocom/pysim.git
|
|
74
|
+
- The test runner defaults to this sibling directory, but you can override it by exporting the `PYSIM_DIR` environment variable.
|
|
75
|
+
- Ensure the virtual environment and requirements are prepared:
|
|
76
|
+
```bash
|
|
77
|
+
cd ../pysim
|
|
78
|
+
python3 -m venv venv
|
|
79
|
+
./venv/bin/pip install -r requirements.txt
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Building the Project
|
|
83
|
+
|
|
84
|
+
This project uses CMake for its build system. Only the LLVM Clang compiler is supported for building the C++ test runner due to GCC's high CPU consumption during parallel compilation.
|
|
85
|
+
|
|
86
|
+
### 1. Build the C++ Test Runner (`ttcn3_runner`)
|
|
87
|
+
Configure and build the runner using the Clang preset:
|
|
88
|
+
```bash
|
|
89
|
+
cmake --preset clang
|
|
90
|
+
cmake --build build-clang -j$(nproc) --target ttcn3_runner
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. Build the UE Simulator (`ttcn3_dut`)
|
|
94
|
+
Build the simulator inside the `srsRAN_5G` subdirectory:
|
|
95
|
+
```bash
|
|
96
|
+
cd srsRAN_5G
|
|
97
|
+
mkdir -p build
|
|
98
|
+
cd build
|
|
99
|
+
cmake .. -G Ninja -DENABLE_TTCN3=ON -DENABLE_TTCN3_NR=ON
|
|
100
|
+
ninja ttcn3_dut
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Managing TTCN-3 Test Suite Versions
|
|
104
|
+
|
|
105
|
+
TTCPP supports multiple co-existing versions of the 3GPP TTCN-3 test suite. Use `./tools/suite_manager.py` to inspect, switch, and transpile test suite versions:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# List all available raw and transpiled test suite versions
|
|
109
|
+
./tools/suite_manager.py list
|
|
110
|
+
|
|
111
|
+
# View detailed information about a specific suite
|
|
112
|
+
./tools/suite_manager.py info NR5GC_IWD_25wk50
|
|
113
|
+
|
|
114
|
+
# Switch the active test suite profile
|
|
115
|
+
./tools/suite_manager.py switch NR5GC_IWD_25wk50
|
|
116
|
+
|
|
117
|
+
# Transpile a new TTCN-3 deliverable package
|
|
118
|
+
./tools/suite_manager.py transpile NR5GC_IWD_26wk08
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Running Tests
|
|
122
|
+
|
|
123
|
+
Test execution is orchestrated using the provided bash script, `./tools/scripts/run_test.sh`. This script handles launching the `ttcn3_dut` and the `ttcn3_runner` processes, establishing their communication via sockets.
|
|
124
|
+
|
|
125
|
+
To run a specific test case (using the active suite):
|
|
126
|
+
```bash
|
|
127
|
+
./tools/scripts/run_test.sh <TEST_CASE_NAME>
|
|
128
|
+
|
|
129
|
+
# Example:
|
|
130
|
+
./tools/scripts/run_test.sh TC_6_1_1_1_NR5GC
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
To run a test case against a specific test suite version:
|
|
134
|
+
```bash
|
|
135
|
+
./tools/scripts/run_test.sh --suite NR5GC_IWD_25wk50 TC_6_1_1_1_NR5GC
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Test logs (such as `tc_runner.log` and `ttcn3_ue.log`) will be populated in a subfolder corresponding to the test case inside the `logs/` directory (e.g., `logs/<TEST_CASE_NAME>/`).
|
|
139
|
+
|
|
140
|
+
## Upper Tester (UT) Integration
|
|
141
|
+
|
|
142
|
+
The test suite orchestrates testcase execution and logs via Upper Tester (UT) interface messages exchanged between the runner (`ttcn3_runner`) and the simulator (`ttcn3_dut`).
|
|
143
|
+
|
|
144
|
+
In addition to standard MMI commands, the runner sends the following native UT control messages:
|
|
145
|
+
- **`TC_START`**: Sent at the start of a test run to initialize the simulator's logging sink and parameterize the simulator logs (e.g., `logs/<TEST_CASE_NAME>/<TEST_CASE_NAME>_run0_ttcn3_ue.log`) dynamically with the active testcase name.
|
|
146
|
+
- JSON payload: `{"Cmd": {"TC_START": {"Name": "<TEST_CASE_NAME>"}}}`
|
|
147
|
+
- **`TC_END`**: Sent at the end of a test run to finalize the simulator's test case context.
|
|
148
|
+
- JSON payload: `{"Cmd": {"TC_END": null}}`
|
|
149
|
+
|
|
150
|
+
## GUI Dashboard
|
|
151
|
+
|
|
152
|
+
A graphical web dashboard is provided to easily manage test execution, view historical test runs, and visualize signaling logs using Mermaid.js MSCs.
|
|
153
|
+
|
|
154
|
+
To start the GUI:
|
|
155
|
+
```bash
|
|
156
|
+
cd tools/gui
|
|
157
|
+
npm install
|
|
158
|
+
npm run dev
|
|
159
|
+
```
|
|
160
|
+
Navigate to `http://localhost:3000` to access the dashboard.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ttcn3_cloud_cli.py,sha256=ABbzeYeScEM1icBBdU3I0fnRMsPliszIaqen5Sl6SaM,47041
|
|
2
|
+
hyper_wireless-1.0.0.dist-info/METADATA,sha256=mI2OL1pHw6uV7YjmuCSH9EcDOehtoIzUrv2YqevQb-o,7820
|
|
3
|
+
hyper_wireless-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
hyper_wireless-1.0.0.dist-info/entry_points.txt,sha256=Rsc9_32-RLm0RsxjnnDJMocX18yOZkdpqYoRHZ9aqQc,85
|
|
5
|
+
hyper_wireless-1.0.0.dist-info/top_level.txt,sha256=fcN7XHL2e8IQqi9eak42OwFbrAgYeClHz4tscAbOXTg,16
|
|
6
|
+
hyper_wireless-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ttcn3_cloud_cli
|
ttcn3_cloud_cli.py
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ttcn3_cloud_cli.py - Client CLI for the TTCN-3 Cloud Testing Service.
|
|
4
|
+
|
|
5
|
+
Enables developers and CI/CD pipelines to run 3GPP conformance test suites in the cloud
|
|
6
|
+
while testing local UEs (such as srsRAN_5G `ttcn3_dut` or hardware UEs) bridged over
|
|
7
|
+
a secure WebSocket multiplex tunnel.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import re
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import socket
|
|
16
|
+
import select
|
|
17
|
+
import signal
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import argparse
|
|
21
|
+
import subprocess
|
|
22
|
+
import shutil
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional, Dict, Any
|
|
25
|
+
|
|
26
|
+
# Auto-detect local virtual environment if missing dependencies in system python
|
|
27
|
+
_venv_python = Path(__file__).resolve().parent.parent / ".venv" / "bin" / "python3"
|
|
28
|
+
if (sys.prefix == sys.base_prefix) and _venv_python.is_file():
|
|
29
|
+
try:
|
|
30
|
+
import requests
|
|
31
|
+
import websockets
|
|
32
|
+
except ImportError:
|
|
33
|
+
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import requests
|
|
37
|
+
except ImportError:
|
|
38
|
+
print("Error: 'requests' package is required.")
|
|
39
|
+
print("Run: source .venv/bin/activate (or: pip install --break-system-packages requests)")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import websockets
|
|
44
|
+
except ImportError:
|
|
45
|
+
print("Error: 'websockets' package is required.")
|
|
46
|
+
print("Run: source .venv/bin/activate (or: pip install --break-system-packages websockets)")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_env_file(path: Path) -> Dict[str, str]:
|
|
51
|
+
"""Reads key-value pairs from an environment/credential file."""
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
return {}
|
|
54
|
+
try:
|
|
55
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
56
|
+
env_vars = {}
|
|
57
|
+
for line in content.splitlines():
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line or line.startswith('#'):
|
|
60
|
+
continue
|
|
61
|
+
if '=' in line:
|
|
62
|
+
k, v = line.split('=', 1)
|
|
63
|
+
k = k.strip().removeprefix('export ').strip()
|
|
64
|
+
v = v.strip().strip('"\'')
|
|
65
|
+
if k:
|
|
66
|
+
env_vars[k] = v
|
|
67
|
+
return env_vars
|
|
68
|
+
except Exception:
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Auto-load candidate credential files
|
|
73
|
+
for env_candidate in [Path.home() / ".jenkins_credentials", Path.home() / ".jenkins_env", Path.cwd() / ".jenkins_env", Path.cwd() / ".jenkins_credentials"]:
|
|
74
|
+
loaded = load_env_file(env_candidate)
|
|
75
|
+
for k, v in loaded.items():
|
|
76
|
+
if k not in os.environ:
|
|
77
|
+
os.environ[k] = v
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CloudClient:
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
endpoint: str,
|
|
85
|
+
api_key: str = "",
|
|
86
|
+
cf_client_id: str = "",
|
|
87
|
+
cf_client_secret: str = ""
|
|
88
|
+
):
|
|
89
|
+
endpoint = (endpoint or "").strip()
|
|
90
|
+
if endpoint and not endpoint.startswith("http://") and not endpoint.startswith("https://"):
|
|
91
|
+
endpoint = f"https://{endpoint}"
|
|
92
|
+
self.endpoint = endpoint.rstrip("/")
|
|
93
|
+
self.headers = {"Content-Type": "application/json"}
|
|
94
|
+
api_key = api_key or os.environ.get("TTCN3_CLOUD_API_KEY", "")
|
|
95
|
+
cf_client_id = cf_client_id or os.environ.get("CF_ACCESS_CLIENT_ID", "")
|
|
96
|
+
cf_client_secret = cf_client_secret or os.environ.get("CF_ACCESS_CLIENT_SECRET", "")
|
|
97
|
+
if api_key:
|
|
98
|
+
self.headers["Authorization"] = f"Bearer {api_key}"
|
|
99
|
+
if cf_client_id and cf_client_secret:
|
|
100
|
+
self.headers["CF-Access-Client-Id"] = cf_client_id
|
|
101
|
+
self.headers["CF-Access-Client-Secret"] = cf_client_secret
|
|
102
|
+
|
|
103
|
+
def get_health(self):
|
|
104
|
+
resp = requests.get(f"{self.endpoint}/health", headers=self.headers, timeout=10)
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
return resp.json()
|
|
107
|
+
|
|
108
|
+
def get_suites(self):
|
|
109
|
+
resp = requests.get(f"{self.endpoint}/api/v1/suites", headers=self.headers, timeout=10)
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
return resp.json()
|
|
112
|
+
|
|
113
|
+
def get_testcases(self, suite: str):
|
|
114
|
+
resp = requests.get(f"{self.endpoint}/api/v1/suites/{suite}/testcases", headers=self.headers, timeout=15)
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
return resp.json()
|
|
117
|
+
|
|
118
|
+
def create_session(
|
|
119
|
+
self,
|
|
120
|
+
testcase: str,
|
|
121
|
+
suite: Optional[str] = None,
|
|
122
|
+
timeout: int = 300,
|
|
123
|
+
log_level: str = "info",
|
|
124
|
+
mode: str = "remote_ue",
|
|
125
|
+
rat: str = "nr",
|
|
126
|
+
timer_mode: str = "balanced",
|
|
127
|
+
timer_step_ms: Optional[int] = None,
|
|
128
|
+
):
|
|
129
|
+
payload = {
|
|
130
|
+
"testcase": testcase,
|
|
131
|
+
"suite": suite,
|
|
132
|
+
"timeout_seconds": timeout,
|
|
133
|
+
"log_level": log_level,
|
|
134
|
+
"mode": mode,
|
|
135
|
+
"rat": rat,
|
|
136
|
+
"timer_mode": timer_mode,
|
|
137
|
+
}
|
|
138
|
+
if timer_step_ms is not None:
|
|
139
|
+
payload["timer_step_ms"] = timer_step_ms
|
|
140
|
+
max_attempts = 15
|
|
141
|
+
for attempt in range(max_attempts):
|
|
142
|
+
try:
|
|
143
|
+
resp = requests.post(f"{self.endpoint}/api/v1/sessions", json=payload, headers=self.headers, timeout=30)
|
|
144
|
+
if resp.status_code in (503, 429) and attempt < max_attempts - 1:
|
|
145
|
+
backoff = min(2 * (attempt + 1), 10)
|
|
146
|
+
print(f"[*] Cloud runner slots busy (HTTP {resp.status_code}). Retrying in {backoff}s (attempt {attempt + 1}/{max_attempts})...")
|
|
147
|
+
time.sleep(backoff)
|
|
148
|
+
continue
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
return resp.json()
|
|
151
|
+
except requests.exceptions.RequestException as e:
|
|
152
|
+
status_code = getattr(getattr(e, 'response', None), 'status_code', None)
|
|
153
|
+
if attempt < max_attempts - 1 and status_code in (503, 429):
|
|
154
|
+
backoff = min(2 * (attempt + 1), 10)
|
|
155
|
+
print(f"[*] Cloud runner slots busy (HTTP {status_code}). Retrying in {backoff}s (attempt {attempt + 1}/{max_attempts})...")
|
|
156
|
+
time.sleep(backoff)
|
|
157
|
+
continue
|
|
158
|
+
raise
|
|
159
|
+
|
|
160
|
+
def get_session(self, session_id: str):
|
|
161
|
+
resp = requests.get(f"{self.endpoint}/api/v1/sessions/{session_id}", headers=self.headers, timeout=10)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
return resp.json()
|
|
164
|
+
|
|
165
|
+
def cancel_session(self, session_id: str):
|
|
166
|
+
resp = requests.delete(f"{self.endpoint}/api/v1/sessions/{session_id}", headers=self.headers, timeout=10)
|
|
167
|
+
resp.raise_for_status()
|
|
168
|
+
return resp.json()
|
|
169
|
+
|
|
170
|
+
def list_artifacts(self, session_id: str):
|
|
171
|
+
try:
|
|
172
|
+
resp = requests.get(f"{self.endpoint}/api/v1/sessions/{session_id}/artifacts", headers=self.headers, timeout=10)
|
|
173
|
+
if resp.status_code == 200:
|
|
174
|
+
return resp.json().get("artifacts", [])
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
def download_artifact(self, session_id: str, filename: str, target_path: Path):
|
|
180
|
+
resp = requests.get(f"{self.endpoint}/api/v1/sessions/{session_id}/artifacts/{filename}", headers=self.headers, stream=True, timeout=30)
|
|
181
|
+
if resp.status_code == 200:
|
|
182
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
with open(target_path, "wb") as f:
|
|
184
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
185
|
+
f.write(chunk)
|
|
186
|
+
return True
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
def download_logs_archive(self, session_id: str, target_path: Path) -> bool:
|
|
190
|
+
"""Downloads all session execution logs in a single compressed .tar.gz bundle."""
|
|
191
|
+
url = f"{self.endpoint}/api/v1/sessions/{session_id}/logs.tar.gz"
|
|
192
|
+
try:
|
|
193
|
+
resp = requests.get(url, headers=self.headers, stream=True, timeout=60)
|
|
194
|
+
if resp.status_code == 200:
|
|
195
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
with open(target_path, "wb") as f:
|
|
197
|
+
for chunk in resp.iter_content(chunk_size=65536):
|
|
198
|
+
if chunk:
|
|
199
|
+
f.write(chunk)
|
|
200
|
+
return True
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_ws_kwargs(headers_dict):
|
|
208
|
+
if not headers_dict:
|
|
209
|
+
return {}
|
|
210
|
+
import inspect
|
|
211
|
+
sig = inspect.signature(websockets.connect)
|
|
212
|
+
if "additional_headers" in sig.parameters:
|
|
213
|
+
return {"additional_headers": headers_dict}
|
|
214
|
+
return {"extra_headers": headers_dict}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def run_local_relay(local_port: int, remote_ws_url: str, headers: dict, stop_event: asyncio.Event, ready_event: Optional[asyncio.Event] = None):
|
|
218
|
+
"""
|
|
219
|
+
Listens on local_port for TCP connections (from srsue/ttcn3_dut) and bridges
|
|
220
|
+
raw bytes to the remote WebSocket MUX tunnel.
|
|
221
|
+
"""
|
|
222
|
+
server = None
|
|
223
|
+
|
|
224
|
+
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
225
|
+
sock = writer.get_extra_info("socket")
|
|
226
|
+
if sock:
|
|
227
|
+
try:
|
|
228
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
print(f"\n[\033[1;34mRELAY\033[0m] Local UE connected on port {local_port}. Connecting to cloud MUX tunnel...")
|
|
233
|
+
try:
|
|
234
|
+
extra_headers = {}
|
|
235
|
+
if "Authorization" in headers:
|
|
236
|
+
extra_headers["Authorization"] = headers["Authorization"]
|
|
237
|
+
if "CF-Access-Client-Id" in headers:
|
|
238
|
+
extra_headers["CF-Access-Client-Id"] = headers["CF-Access-Client-Id"]
|
|
239
|
+
extra_headers["CF-Access-Client-Secret"] = headers["CF-Access-Client-Secret"]
|
|
240
|
+
|
|
241
|
+
ws_kwargs = get_ws_kwargs(extra_headers)
|
|
242
|
+
|
|
243
|
+
ws = None
|
|
244
|
+
for attempt in range(10):
|
|
245
|
+
if stop_event.is_set():
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
ws = await websockets.connect(
|
|
249
|
+
remote_ws_url,
|
|
250
|
+
ping_interval=20,
|
|
251
|
+
ping_timeout=20,
|
|
252
|
+
max_size=10 * 1024 * 1024,
|
|
253
|
+
**ws_kwargs,
|
|
254
|
+
)
|
|
255
|
+
break
|
|
256
|
+
except Exception as ce:
|
|
257
|
+
if attempt == 9:
|
|
258
|
+
print(f"[\033[1;31mRELAY ERROR\033[0m] Failed to connect to cloud MUX tunnel at {remote_ws_url}: {ce}")
|
|
259
|
+
return
|
|
260
|
+
await asyncio.sleep(0.3)
|
|
261
|
+
|
|
262
|
+
if not ws:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
async with ws:
|
|
266
|
+
print(f"[\033[1;32mRELAY\033[0m] Cloud MUX WebSocket tunnel established.")
|
|
267
|
+
|
|
268
|
+
is_done = False
|
|
269
|
+
|
|
270
|
+
async def tcp_to_ws():
|
|
271
|
+
nonlocal is_done
|
|
272
|
+
try:
|
|
273
|
+
while not is_done and not stop_event.is_set():
|
|
274
|
+
data = await reader.read(65536)
|
|
275
|
+
if not data:
|
|
276
|
+
break
|
|
277
|
+
await ws.send(data)
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
finally:
|
|
281
|
+
is_done = True
|
|
282
|
+
|
|
283
|
+
async def ws_to_tcp():
|
|
284
|
+
nonlocal is_done
|
|
285
|
+
try:
|
|
286
|
+
while not is_done and not stop_event.is_set():
|
|
287
|
+
msg = await ws.recv()
|
|
288
|
+
if isinstance(msg, bytes):
|
|
289
|
+
writer.write(msg)
|
|
290
|
+
await writer.drain()
|
|
291
|
+
elif isinstance(msg, str):
|
|
292
|
+
writer.write(msg.encode("utf-8"))
|
|
293
|
+
await writer.drain()
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
finally:
|
|
297
|
+
is_done = True
|
|
298
|
+
|
|
299
|
+
t1 = asyncio.create_task(tcp_to_ws())
|
|
300
|
+
t2 = asyncio.create_task(ws_to_tcp())
|
|
301
|
+
await asyncio.wait([t1, t2], return_when=asyncio.FIRST_COMPLETED)
|
|
302
|
+
is_done = True
|
|
303
|
+
t1.cancel()
|
|
304
|
+
t2.cancel()
|
|
305
|
+
|
|
306
|
+
except Exception as e:
|
|
307
|
+
print(f"[\033[1;31mRELAY ERROR\033[0m] Error in relay bridge: {e}")
|
|
308
|
+
finally:
|
|
309
|
+
try:
|
|
310
|
+
writer.close()
|
|
311
|
+
await writer.wait_closed()
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
print(f"[\033[1;34mRELAY\033[0m] Relay connection closed.")
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
server = await asyncio.start_server(handle_client, "127.0.0.1", local_port)
|
|
318
|
+
print(f"[\033[1;32mRELAY\033[0m] Local TCP Multiplex Relay listening on 127.0.0.1:{local_port}")
|
|
319
|
+
if ready_event:
|
|
320
|
+
ready_event.set()
|
|
321
|
+
while not stop_event.is_set():
|
|
322
|
+
await asyncio.sleep(0.5)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
print(f"[\033[1;31mRELAY ERROR\033[0m] Failed to bind local relay on port {local_port}: {e}")
|
|
325
|
+
finally:
|
|
326
|
+
if server:
|
|
327
|
+
server.close()
|
|
328
|
+
await server.wait_closed()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
SIGNALING_PATTERNS = [
|
|
332
|
+
(re.compile(r'Starting execution of target testcase:\s*(\S+)'), 'Test Case Execution Started: \\1'),
|
|
333
|
+
(re.compile(r'\"rrcSetupRequest\"'), 'RRC: RRCSetupRequest (UE -> Network)'),
|
|
334
|
+
(re.compile(r'\"rrcSetup\"'), 'RRC: RRCSetup (Network -> UE)'),
|
|
335
|
+
(re.compile(r'\"rrcSetupComplete\"'), 'RRC: RRCSetupComplete (UE -> Network)'),
|
|
336
|
+
(re.compile(r'\"securityModeCommand\"'), 'RRC: SecurityModeCommand (Network -> UE)'),
|
|
337
|
+
(re.compile(r'\"securityModeComplete\"'), 'RRC: SecurityModeComplete (UE -> Network)'),
|
|
338
|
+
(re.compile(r'\"rrcReconfiguration\"'), 'RRC: RRCReconfiguration (Network -> UE)'),
|
|
339
|
+
(re.compile(r'\"rrcReconfigurationComplete\"'), 'RRC: RRCReconfigurationComplete (UE -> Network)'),
|
|
340
|
+
(re.compile(r'\"rrcRelease\"'), 'RRC: RRCRelease (Network -> UE)'),
|
|
341
|
+
(re.compile(r'f_NR_PreliminaryPass.*\"(Step[^\"]+)\"'), 'Preliminary Pass: \\1'),
|
|
342
|
+
(re.compile(r'Active cell changed to (\d+)'), 'Cell Configuration: Active Cell -> \\1'),
|
|
343
|
+
(re.compile(r'AS Security StartRestart.*CellId (\d+)'), 'Security: AS Security Activated (Cell \\1)'),
|
|
344
|
+
(re.compile(r'AS Security Release.*CellId (\d+)'), 'Security: AS Security Released (Cell \\1)'),
|
|
345
|
+
(re.compile(r'Testcase gracefully stopped'), 'Test Case Execution Stopped Gracefully'),
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def format_elapsed(seconds: float) -> str:
|
|
350
|
+
m = int(seconds) // 60
|
|
351
|
+
s = int(seconds) % 60
|
|
352
|
+
return f"{m:02d}:{s:02d}"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def stream_logs(remote_ws_url: str, headers: dict, stop_event: asyncio.Event, verdict_holder: dict, raw_logs: bool = False, start_time: Optional[float] = None):
|
|
356
|
+
"""
|
|
357
|
+
Subscribes to the live log stream WebSocket on the cloud runner and displays
|
|
358
|
+
real-time test progress (steps, signaling milestones, verdicts, elapsed time).
|
|
359
|
+
"""
|
|
360
|
+
extra_headers = {}
|
|
361
|
+
if "Authorization" in headers:
|
|
362
|
+
extra_headers["Authorization"] = headers["Authorization"]
|
|
363
|
+
if "CF-Access-Client-Id" in headers:
|
|
364
|
+
extra_headers["CF-Access-Client-Id"] = headers["CF-Access-Client-Id"]
|
|
365
|
+
extra_headers["CF-Access-Client-Secret"] = headers["CF-Access-Client-Secret"]
|
|
366
|
+
|
|
367
|
+
ws_kwargs = get_ws_kwargs(extra_headers)
|
|
368
|
+
|
|
369
|
+
ws = None
|
|
370
|
+
last_err = None
|
|
371
|
+
for _ in range(15):
|
|
372
|
+
if stop_event.is_set():
|
|
373
|
+
return
|
|
374
|
+
try:
|
|
375
|
+
ws = await websockets.connect(remote_ws_url, **ws_kwargs)
|
|
376
|
+
break
|
|
377
|
+
except Exception as e:
|
|
378
|
+
last_err = e
|
|
379
|
+
await asyncio.sleep(0.3)
|
|
380
|
+
|
|
381
|
+
if not ws:
|
|
382
|
+
print(f"[\033[1;31mSTREAM ERROR\033[0m] Could not connect to cloud log stream at {remote_ws_url}: {last_err}", flush=True)
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
if start_time is None:
|
|
386
|
+
start_time = time.time()
|
|
387
|
+
current_step = None
|
|
388
|
+
last_event = None
|
|
389
|
+
last_heartbeat_time = time.time()
|
|
390
|
+
|
|
391
|
+
def print_progress(text: str, event_type: str = "milestone"):
|
|
392
|
+
elapsed = format_elapsed(time.time() - start_time)
|
|
393
|
+
if event_type == "step":
|
|
394
|
+
print(f"\n\033[1;36m[{elapsed}] ───▶ {text}\033[0m", flush=True)
|
|
395
|
+
elif event_type == "verdict":
|
|
396
|
+
color = "\033[1;32m" if "PASS" in text else "\033[1;31m"
|
|
397
|
+
print(f"\n{color}[{elapsed}] =========================================================================\033[0m", flush=True)
|
|
398
|
+
print(f"{color}[{elapsed}] >>> {text}\033[0m", flush=True)
|
|
399
|
+
print(f"{color}[{elapsed}] =========================================================================\033[0m\n", flush=True)
|
|
400
|
+
elif event_type == "pass":
|
|
401
|
+
print(f" \033[1;32m✔\033[0m \033[32m[{elapsed}] {text}\033[0m", flush=True)
|
|
402
|
+
elif event_type == "status":
|
|
403
|
+
print(f"\033[1;33m[{elapsed}] [*] {text}\033[0m", flush=True)
|
|
404
|
+
elif event_type == "error":
|
|
405
|
+
print(f"\033[1;31m[{elapsed}] [!] {text}\033[0m", flush=True)
|
|
406
|
+
else:
|
|
407
|
+
print(f" \033[1;34m✔\033[0m \033[90m[{elapsed}]\033[0m {text}", flush=True)
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
async with ws:
|
|
411
|
+
while not stop_event.is_set():
|
|
412
|
+
try:
|
|
413
|
+
msg_text = await asyncio.wait_for(ws.recv(), timeout=1.0)
|
|
414
|
+
msg = json.loads(msg_text)
|
|
415
|
+
mtype = msg.get("type", "log")
|
|
416
|
+
content = msg.get("content", "")
|
|
417
|
+
|
|
418
|
+
if mtype == "step":
|
|
419
|
+
if content != current_step:
|
|
420
|
+
current_step = content
|
|
421
|
+
print_progress(content, event_type="step")
|
|
422
|
+
last_event = None
|
|
423
|
+
last_heartbeat_time = time.time()
|
|
424
|
+
elif mtype == "verdict":
|
|
425
|
+
if not verdict_holder.get("verdict"):
|
|
426
|
+
verdict_holder["verdict"] = content
|
|
427
|
+
print_progress(f"Final Test Verdict: {content}", event_type="verdict")
|
|
428
|
+
elif mtype == "status":
|
|
429
|
+
print_progress(content, event_type="status")
|
|
430
|
+
elif mtype == "error":
|
|
431
|
+
print_progress(content, event_type="error")
|
|
432
|
+
else:
|
|
433
|
+
if raw_logs:
|
|
434
|
+
sys.stdout.write(content)
|
|
435
|
+
sys.stdout.flush()
|
|
436
|
+
else:
|
|
437
|
+
for raw_line in content.splitlines():
|
|
438
|
+
clean = re.sub(r'^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?\]\s*', '', raw_line.strip())
|
|
439
|
+
if re.match(r'^Steps?\s+[0-9a-zA-Z]', clean, re.IGNORECASE) and len(clean) < 60:
|
|
440
|
+
if clean != current_step:
|
|
441
|
+
current_step = clean
|
|
442
|
+
print_progress(clean, event_type="step")
|
|
443
|
+
last_event = None
|
|
444
|
+
last_heartbeat_time = time.time()
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
if "Final Test Verdict:" in clean:
|
|
448
|
+
v = clean.split("Final Test Verdict:", 1)[1].strip()
|
|
449
|
+
if not verdict_holder.get("verdict"):
|
|
450
|
+
verdict_holder["verdict"] = v
|
|
451
|
+
print_progress(f"Final Test Verdict: {v}", event_type="verdict")
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
if "PreliminaryPass" in clean or "f_NR_PreliminaryPass" in clean:
|
|
455
|
+
m = re.search(r'PreliminaryPass.*\"([^\"]+)\"', clean)
|
|
456
|
+
pass_name = m.group(1) if m else "Pass"
|
|
457
|
+
print_progress(f"Preliminary Pass: {pass_name}", event_type="pass")
|
|
458
|
+
last_heartbeat_time = time.time()
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
for pat, label in SIGNALING_PATTERNS:
|
|
462
|
+
m = pat.search(clean)
|
|
463
|
+
if m:
|
|
464
|
+
desc = label
|
|
465
|
+
if '\\1' in label and m.groups():
|
|
466
|
+
desc = label.replace('\\1', m.group(1))
|
|
467
|
+
if desc != last_event:
|
|
468
|
+
last_event = desc
|
|
469
|
+
print_progress(desc, event_type="milestone")
|
|
470
|
+
last_heartbeat_time = time.time()
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
if "[RUNNER_STATUS]" in clean:
|
|
474
|
+
print_progress(clean, event_type="status")
|
|
475
|
+
|
|
476
|
+
except asyncio.TimeoutError:
|
|
477
|
+
if not raw_logs and (time.time() - last_heartbeat_time > 10.0):
|
|
478
|
+
elapsed = format_elapsed(time.time() - start_time)
|
|
479
|
+
step_info = f" ({current_step})" if current_step else ""
|
|
480
|
+
print(f" \033[90m... [{elapsed}] Executing{step_info}\033[0m", flush=True)
|
|
481
|
+
last_heartbeat_time = time.time()
|
|
482
|
+
continue
|
|
483
|
+
except websockets.ConnectionClosed:
|
|
484
|
+
break
|
|
485
|
+
except Exception as e:
|
|
486
|
+
print(f"[\033[1;33mSTREAM NOTE\033[0m] Stream closed: {e}", flush=True)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def run_command_handler(args):
|
|
490
|
+
client = CloudClient(
|
|
491
|
+
endpoint=args.endpoint,
|
|
492
|
+
api_key=args.token or os.environ.get("TTCN3_CLOUD_API_KEY", ""),
|
|
493
|
+
cf_client_id=args.cf_client_id or os.environ.get("CF_ACCESS_CLIENT_ID", ""),
|
|
494
|
+
cf_client_secret=args.cf_client_secret or os.environ.get("CF_ACCESS_CLIENT_SECRET", ""),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
exec_mode = getattr(args, "mode", None)
|
|
498
|
+
if not exec_mode:
|
|
499
|
+
if getattr(args, "srsue", False):
|
|
500
|
+
exec_mode = "remote_ue"
|
|
501
|
+
else:
|
|
502
|
+
exec_mode = "cloud_ue"
|
|
503
|
+
|
|
504
|
+
mode_label = "Cloud Headless UE (Colocated High-Speed)" if exec_mode == "cloud_ue" else "Remote UE (Local Relay)"
|
|
505
|
+
timer_mode = getattr(args, "timer_mode", "adaptive")
|
|
506
|
+
timer_step_ms = getattr(args, "timer_step_ms", None)
|
|
507
|
+
if timer_step_ms:
|
|
508
|
+
step_desc = f"{timer_step_ms}ms"
|
|
509
|
+
elif timer_mode == "accurate":
|
|
510
|
+
step_desc = "100ms"
|
|
511
|
+
elif timer_mode == "fast":
|
|
512
|
+
step_desc = "2000ms"
|
|
513
|
+
elif timer_mode == "balanced":
|
|
514
|
+
step_desc = "500ms"
|
|
515
|
+
elif timer_mode == "adaptive":
|
|
516
|
+
step_desc = "dynamic 100ms - 5000ms (Gear 3 auto-geared)"
|
|
517
|
+
else:
|
|
518
|
+
step_desc = "500ms"
|
|
519
|
+
|
|
520
|
+
timeout = getattr(args, "timeout", None)
|
|
521
|
+
if timeout is None:
|
|
522
|
+
timeout = 900 if exec_mode == "remote_ue" else 600
|
|
523
|
+
timeout_desc = f"{timeout}s ({timeout // 60} mins)" if timeout % 60 == 0 else f"{timeout}s"
|
|
524
|
+
|
|
525
|
+
print("\n=========================================================================")
|
|
526
|
+
print(f" TTCN-3 Cloud Runner - Test Session Initialization")
|
|
527
|
+
print("=========================================================================")
|
|
528
|
+
print(f" Cloud Endpoint : {args.endpoint}")
|
|
529
|
+
print(f" Test Case : {args.tc}")
|
|
530
|
+
print(f" Execution Mode : {mode_label}")
|
|
531
|
+
print(f" Simulation Time: {timer_mode} mode (max step: {step_desc})")
|
|
532
|
+
print(f" Timeout : {timeout_desc}")
|
|
533
|
+
if args.suite:
|
|
534
|
+
print(f" Suite Version : {args.suite}")
|
|
535
|
+
print("=========================================================================\n")
|
|
536
|
+
|
|
537
|
+
print("[1/4] Creating remote test session in the cloud...")
|
|
538
|
+
try:
|
|
539
|
+
session = client.create_session(
|
|
540
|
+
testcase=args.tc,
|
|
541
|
+
suite=args.suite,
|
|
542
|
+
timeout=timeout,
|
|
543
|
+
log_level=args.loglevel,
|
|
544
|
+
mode=exec_mode,
|
|
545
|
+
rat=getattr(args, "rat", "nr"),
|
|
546
|
+
timer_mode=timer_mode,
|
|
547
|
+
timer_step_ms=timer_step_ms,
|
|
548
|
+
)
|
|
549
|
+
except Exception as e:
|
|
550
|
+
print(f"Error creating session: {e}")
|
|
551
|
+
return 1
|
|
552
|
+
|
|
553
|
+
session_id = session["session_id"]
|
|
554
|
+
slot = session["slot"]
|
|
555
|
+
cloud_mux_port = session["ports"]["multiplex"]
|
|
556
|
+
vpcd_port = session["ports"]["vpcd"]
|
|
557
|
+
|
|
558
|
+
# Use specified local port or avoid collision if testing on localhost
|
|
559
|
+
is_localhost = "127.0.0.1" in args.endpoint or "localhost" in args.endpoint
|
|
560
|
+
if args.local_port:
|
|
561
|
+
local_port = args.local_port
|
|
562
|
+
elif is_localhost:
|
|
563
|
+
local_port = cloud_mux_port + 1000
|
|
564
|
+
else:
|
|
565
|
+
local_port = cloud_mux_port
|
|
566
|
+
|
|
567
|
+
# Resolve URLs
|
|
568
|
+
stream_url = session["ws_stream_url"]
|
|
569
|
+
mux_url = session["ws_mux_url"]
|
|
570
|
+
|
|
571
|
+
# If URLs are relative, make absolute based on endpoint
|
|
572
|
+
if stream_url.startswith("/"):
|
|
573
|
+
ws_base = args.endpoint.replace("https://", "wss://").replace("http://", "ws://")
|
|
574
|
+
stream_url = f"{ws_base}{stream_url}"
|
|
575
|
+
if mux_url.startswith("/"):
|
|
576
|
+
ws_base = args.endpoint.replace("https://", "wss://").replace("http://", "ws://")
|
|
577
|
+
mux_url = f"{ws_base}{mux_url}"
|
|
578
|
+
|
|
579
|
+
print(f" Allocated Cloud Slot: {slot}")
|
|
580
|
+
print(f" Cloud Session ID : {session_id}")
|
|
581
|
+
if exec_mode == "cloud_ue":
|
|
582
|
+
print(f" Cloud Multiplex Port: {cloud_mux_port} (Internal Loopback)")
|
|
583
|
+
else:
|
|
584
|
+
print(f" Local Multiplex Port: {local_port}")
|
|
585
|
+
print(f" Cloud Multiplex Port: {cloud_mux_port}")
|
|
586
|
+
|
|
587
|
+
# Start asyncio loop for relay and streaming
|
|
588
|
+
loop = asyncio.new_event_loop()
|
|
589
|
+
asyncio.set_event_loop(loop)
|
|
590
|
+
stop_event = asyncio.Event()
|
|
591
|
+
verdict_holder = {"verdict": None}
|
|
592
|
+
|
|
593
|
+
dut_proc = None
|
|
594
|
+
softsim_proc = None
|
|
595
|
+
ue_stdout_file = None
|
|
596
|
+
softsim_file = None
|
|
597
|
+
|
|
598
|
+
def cleanup_all():
|
|
599
|
+
stop_event.set()
|
|
600
|
+
if dut_proc and dut_proc.poll() is None:
|
|
601
|
+
print("\nStopping local UE simulator...")
|
|
602
|
+
try:
|
|
603
|
+
os.killpg(os.getpgid(dut_proc.pid), signal.SIGKILL)
|
|
604
|
+
except Exception:
|
|
605
|
+
try:
|
|
606
|
+
dut_proc.terminate()
|
|
607
|
+
except Exception:
|
|
608
|
+
dut_proc.kill()
|
|
609
|
+
if softsim_proc and softsim_proc.poll() is None:
|
|
610
|
+
print("Stopping local SoftSIM...")
|
|
611
|
+
try:
|
|
612
|
+
os.killpg(os.getpgid(softsim_proc.pid), signal.SIGKILL)
|
|
613
|
+
except Exception:
|
|
614
|
+
pass
|
|
615
|
+
if ue_stdout_file and not ue_stdout_file.closed:
|
|
616
|
+
try:
|
|
617
|
+
ue_stdout_file.close()
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
if softsim_file and not softsim_file.closed:
|
|
621
|
+
try:
|
|
622
|
+
softsim_file.close()
|
|
623
|
+
except Exception:
|
|
624
|
+
pass
|
|
625
|
+
if exec_mode != "cloud_ue":
|
|
626
|
+
subprocess.run(f"pkill -9 -f 'softsim.*{vpcd_port}' >/dev/null 2>&1 || true", shell=True)
|
|
627
|
+
subprocess.run(f"pkill -9 -f 'softsim_instance_{slot}' >/dev/null 2>&1 || true", shell=True)
|
|
628
|
+
subprocess.run(f"fuser -k {vpcd_port}/tcp >/dev/null 2>&1 || true", shell=True)
|
|
629
|
+
subprocess.run(f"fuser -k {local_port}/tcp >/dev/null 2>&1 || true", shell=True)
|
|
630
|
+
|
|
631
|
+
async def main_async():
|
|
632
|
+
nonlocal dut_proc, softsim_proc, ue_stdout_file, softsim_file
|
|
633
|
+
|
|
634
|
+
raw_logs_mode = getattr(args, "raw_logs", False)
|
|
635
|
+
common_start_time = time.time()
|
|
636
|
+
ue_monitor_task = None
|
|
637
|
+
|
|
638
|
+
if exec_mode == "cloud_ue":
|
|
639
|
+
print("\n[2/4] Headless UE (`ttcn3_dut` + SoftSIM) running in cloud alongside runner...")
|
|
640
|
+
print("\n[3/4] Streaming Live Test Progress from Cloud Runner...")
|
|
641
|
+
print("-------------------------------------------------------------------------")
|
|
642
|
+
stream_task = asyncio.create_task(stream_logs(stream_url, client.headers, stop_event, verdict_holder, raw_logs=raw_logs_mode, start_time=common_start_time))
|
|
643
|
+
relay_task = None
|
|
644
|
+
else:
|
|
645
|
+
if exec_mode != "cloud_ue":
|
|
646
|
+
subprocess.run(f"pkill -9 -f 'softsim.*{vpcd_port}' >/dev/null 2>&1 || true", shell=True)
|
|
647
|
+
subprocess.run(f"pkill -9 -f 'softsim_instance_{slot}' >/dev/null 2>&1 || true", shell=True)
|
|
648
|
+
subprocess.run(f"fuser -k {local_port}/tcp >/dev/null 2>&1 || true", shell=True)
|
|
649
|
+
subprocess.run(f"fuser -k {vpcd_port}/tcp >/dev/null 2>&1 || true", shell=True)
|
|
650
|
+
|
|
651
|
+
ready_event = asyncio.Event()
|
|
652
|
+
relay_task = asyncio.create_task(run_local_relay(local_port, mux_url, client.headers, stop_event, ready_event))
|
|
653
|
+
stream_task = asyncio.create_task(stream_logs(stream_url, client.headers, stop_event, verdict_holder, raw_logs=raw_logs_mode, start_time=common_start_time))
|
|
654
|
+
|
|
655
|
+
# Wait for local TCP relay to start listening
|
|
656
|
+
await ready_event.wait()
|
|
657
|
+
|
|
658
|
+
# Optional local srsRAN_5G / softsim launch
|
|
659
|
+
if args.srsue:
|
|
660
|
+
print("\n[2/4] Starting local SoftSIM & srsRAN_5G UE (`ttcn3_dut`)...")
|
|
661
|
+
ttcpp_dir = Path(__file__).resolve().parent.parent
|
|
662
|
+
dev_dir = ttcpp_dir.parent
|
|
663
|
+
onomondo_dir = Path(os.environ.get("ONOMONDO_UICC_DIR", dev_dir / "onomondo-uicc"))
|
|
664
|
+
srsran_dir = Path(os.environ.get("SRSRAN_DIR", dev_dir / "srsRAN_5G"))
|
|
665
|
+
|
|
666
|
+
softsim_bin = onomondo_dir / "build" / "src" / "softsim" / "softsim"
|
|
667
|
+
dut_bin = srsran_dir / "build" / "bin" / "ttcn3_dut"
|
|
668
|
+
|
|
669
|
+
if not softsim_bin.exists() and (ttcpp_dir / "bin/softsim").exists():
|
|
670
|
+
softsim_bin = ttcpp_dir / "bin/softsim"
|
|
671
|
+
if not dut_bin.exists() and (ttcpp_dir / "bin/ttcn3_dut").exists():
|
|
672
|
+
dut_bin = ttcpp_dir / "bin/ttcn3_dut"
|
|
673
|
+
|
|
674
|
+
files_dir = onomondo_dir / "files"
|
|
675
|
+
if not files_dir.exists() and (ttcpp_dir / "files").exists():
|
|
676
|
+
files_dir = ttcpp_dir / "files"
|
|
677
|
+
|
|
678
|
+
dut_log_dir = ttcpp_dir / f"logs/{args.tc}"
|
|
679
|
+
dut_log_dir.mkdir(parents=True, exist_ok=True)
|
|
680
|
+
|
|
681
|
+
if softsim_bin.exists():
|
|
682
|
+
subprocess.run(f"pkill -9 -f 'softsim.*{vpcd_port}' >/dev/null 2>&1 || true", shell=True)
|
|
683
|
+
subprocess.run(f"pkill -9 -f 'softsim_instance_{slot}' >/dev/null 2>&1 || true", shell=True)
|
|
684
|
+
softsim_dir = ttcpp_dir / f"logs/softsim_instance_{slot}"
|
|
685
|
+
if softsim_dir.exists():
|
|
686
|
+
shutil.rmtree(softsim_dir, ignore_errors=True)
|
|
687
|
+
softsim_dir.mkdir(parents=True, exist_ok=True)
|
|
688
|
+
if files_dir.exists():
|
|
689
|
+
shutil.copytree(files_dir, softsim_dir / "files", dirs_exist_ok=True)
|
|
690
|
+
sys.path.append(str(ttcpp_dir / "tools" / "usim_configs"))
|
|
691
|
+
try:
|
|
692
|
+
from softsim_utils import get_init_script_for_testcase
|
|
693
|
+
init_script = Path(get_init_script_for_testcase(args.tc, str(ttcpp_dir / "tools" / "usim_configs")))
|
|
694
|
+
except Exception:
|
|
695
|
+
init_script = ttcpp_dir / "tools/usim_configs/init_softsim_config_1.py"
|
|
696
|
+
if init_script.exists():
|
|
697
|
+
env_init = os.environ.copy()
|
|
698
|
+
env_init["PYTHONPATH"] = str(ttcpp_dir / "tools" / "usim_configs")
|
|
699
|
+
subprocess.run([sys.executable, str(init_script), str(softsim_dir)], env=env_init, check=True, stdout=subprocess.DEVNULL)
|
|
700
|
+
|
|
701
|
+
softsim_file = open(dut_log_dir / "softsim.log", "w", encoding="utf-8", errors="replace")
|
|
702
|
+
softsim_loop_cmd = f"while true; do '{softsim_bin}' {vpcd_port} || true; sleep 0.2; done"
|
|
703
|
+
softsim_proc = subprocess.Popen(
|
|
704
|
+
softsim_loop_cmd,
|
|
705
|
+
shell=True,
|
|
706
|
+
cwd=str(softsim_dir),
|
|
707
|
+
stdout=softsim_file,
|
|
708
|
+
stderr=subprocess.STDOUT,
|
|
709
|
+
preexec_fn=os.setsid,
|
|
710
|
+
)
|
|
711
|
+
print(f" SoftSIM running on port {vpcd_port} (PID: {softsim_proc.pid})")
|
|
712
|
+
await asyncio.sleep(0.5)
|
|
713
|
+
|
|
714
|
+
if dut_bin.exists():
|
|
715
|
+
dut_cmd = [
|
|
716
|
+
str(dut_bin),
|
|
717
|
+
"--tester.instance_id", str(slot),
|
|
718
|
+
"--tester.ip", "127.0.0.1",
|
|
719
|
+
"--tester.port", str(local_port),
|
|
720
|
+
"--usim.mode", "vpcd",
|
|
721
|
+
"--rat", args.rat,
|
|
722
|
+
"--loglevel", args.loglevel,
|
|
723
|
+
"--logfilename", str(dut_log_dir / "ttcn3_ue.log"),
|
|
724
|
+
"--log.file_max_size=-1",
|
|
725
|
+
"--log.file_max_size_truncate=true",
|
|
726
|
+
"--at.port", str(27007 + slot * 20),
|
|
727
|
+
]
|
|
728
|
+
dut_env = os.environ.copy()
|
|
729
|
+
dut_env["INSTANCE_ID"] = str(slot)
|
|
730
|
+
dut_env["TESTER_IP"] = "127.0.0.1"
|
|
731
|
+
dut_env["TESTER_PORT"] = str(local_port)
|
|
732
|
+
dut_env["SRSRAN_POOL_SIZE"] = "512"
|
|
733
|
+
ue_stdout_file = open(dut_log_dir / "ue_stdout.log", "w", encoding="utf-8", errors="replace")
|
|
734
|
+
dut_proc = subprocess.Popen(
|
|
735
|
+
dut_cmd,
|
|
736
|
+
cwd=str(ttcpp_dir),
|
|
737
|
+
env=dut_env,
|
|
738
|
+
stdout=ue_stdout_file,
|
|
739
|
+
stderr=subprocess.STDOUT,
|
|
740
|
+
preexec_fn=os.setsid,
|
|
741
|
+
)
|
|
742
|
+
print(f" srsRAN_5G `ttcn3_dut` launched (PID: {dut_proc.pid})")
|
|
743
|
+
|
|
744
|
+
async def monitor_local_ue():
|
|
745
|
+
ue_log_file = dut_log_dir / "ue_stdout.log"
|
|
746
|
+
local_patterns = [
|
|
747
|
+
(re.compile(r"Initializing TTCN3 Mux Client"), "Local UE: Connecting to cloud multiplex relay"),
|
|
748
|
+
(re.compile(r"Switching on UE"), "Local UE: Powering ON stack"),
|
|
749
|
+
(re.compile(r"RRC_NR: SIB1 received! setting for pci=(\d+)"), "Local UE: SIB1 acquired (PCI \\1)"),
|
|
750
|
+
(re.compile(r"RRC_NR: (?:send_setup_request|connection_request)"), "Local UE: Transmitting RRCSetupRequest"),
|
|
751
|
+
(re.compile(r"MAC_NR: Calling phy->send_prach"), "Local UE: Transmitting PRACH Preamble"),
|
|
752
|
+
(re.compile(r"MAC_NR: ra_response_reception"), "Local UE: Random Access Response (RAR) Received"),
|
|
753
|
+
(re.compile(r"Applying security context|AS Security"), "Local UE: AS Security Activated"),
|
|
754
|
+
(re.compile(r"Received RRC Release|rrcRelease"), "Local UE: RRC Release Received"),
|
|
755
|
+
]
|
|
756
|
+
seen_events = set()
|
|
757
|
+
while not stop_event.is_set():
|
|
758
|
+
if ue_log_file.exists():
|
|
759
|
+
try:
|
|
760
|
+
with open(ue_log_file, "r", encoding="utf-8", errors="replace") as f:
|
|
761
|
+
for line in f:
|
|
762
|
+
for pat, desc in local_patterns:
|
|
763
|
+
m = pat.search(line)
|
|
764
|
+
if m and desc not in seen_events:
|
|
765
|
+
seen_events.add(desc)
|
|
766
|
+
actual_desc = desc
|
|
767
|
+
if "\\1" in desc and m.groups():
|
|
768
|
+
actual_desc = desc.replace("\\1", m.group(1))
|
|
769
|
+
elapsed = format_elapsed(time.time() - common_start_time)
|
|
770
|
+
print(f" \033[1;35m⚙\033[0m \033[90m[{elapsed}]\033[0m \033[35m{actual_desc}\033[0m", flush=True)
|
|
771
|
+
except Exception:
|
|
772
|
+
pass
|
|
773
|
+
await asyncio.sleep(0.3)
|
|
774
|
+
|
|
775
|
+
ue_monitor_task = asyncio.create_task(monitor_local_ue())
|
|
776
|
+
else:
|
|
777
|
+
print(f"\n[2/4] Awaiting local UE connection on 127.0.0.1:{local_port}...")
|
|
778
|
+
print(f" (Start your local UE/srsue pointing to tester port {local_port})")
|
|
779
|
+
|
|
780
|
+
print("\n[3/4] Establishing Cloud Tunnel & Streaming Logs...")
|
|
781
|
+
print("-------------------------------------------------------------------------")
|
|
782
|
+
|
|
783
|
+
# Check session completion periodically
|
|
784
|
+
async def poll_status():
|
|
785
|
+
while not stop_event.is_set():
|
|
786
|
+
await asyncio.sleep(1.0)
|
|
787
|
+
try:
|
|
788
|
+
s = client.get_session(session_id)
|
|
789
|
+
if s["status"] in ("COMPLETED", "FAILED", "TIMEOUT", "CANCELLED"):
|
|
790
|
+
verdict_holder["verdict"] = s.get("verdict")
|
|
791
|
+
if s.get("error_message"):
|
|
792
|
+
print(f"\n\033[1;31m[CLOUD RUNNER ERROR]\033[0m {s['error_message']}\n", flush=True)
|
|
793
|
+
stop_event.set()
|
|
794
|
+
break
|
|
795
|
+
except Exception:
|
|
796
|
+
pass
|
|
797
|
+
|
|
798
|
+
poll_task = asyncio.create_task(poll_status())
|
|
799
|
+
|
|
800
|
+
# Wait until stop_event is set
|
|
801
|
+
while not stop_event.is_set():
|
|
802
|
+
await asyncio.sleep(0.5)
|
|
803
|
+
|
|
804
|
+
if ue_monitor_task:
|
|
805
|
+
ue_monitor_task.cancel()
|
|
806
|
+
if relay_task:
|
|
807
|
+
relay_task.cancel()
|
|
808
|
+
stream_task.cancel()
|
|
809
|
+
poll_task.cancel()
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
loop.run_until_complete(main_async())
|
|
813
|
+
except KeyboardInterrupt:
|
|
814
|
+
print("\n\nTest interrupted by user. Cancelling cloud session...")
|
|
815
|
+
try:
|
|
816
|
+
client.cancel_session(session_id)
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
finally:
|
|
820
|
+
cleanup_all()
|
|
821
|
+
|
|
822
|
+
# 4. Summary & Artifact Download
|
|
823
|
+
print("-------------------------------------------------------------------------")
|
|
824
|
+
print("\n=========================================================================")
|
|
825
|
+
print(" TEST EXECUTION SUMMARY")
|
|
826
|
+
print("=========================================================================")
|
|
827
|
+
final_session = {}
|
|
828
|
+
try:
|
|
829
|
+
final_session = client.get_session(session_id)
|
|
830
|
+
verdict = final_session.get("verdict") or verdict_holder.get("verdict") or "UNKNOWN"
|
|
831
|
+
duration = final_session.get("duration_seconds", "N/A")
|
|
832
|
+
print(f" Test Case : {args.tc}")
|
|
833
|
+
print(f" Suite : {final_session.get('suite', args.suite)}")
|
|
834
|
+
print(f" Duration : {duration}s")
|
|
835
|
+
print(f" Verdict : {verdict}")
|
|
836
|
+
except Exception:
|
|
837
|
+
verdict = verdict_holder.get("verdict") or "UNKNOWN"
|
|
838
|
+
print(f" Verdict : {verdict}")
|
|
839
|
+
|
|
840
|
+
if args.download_artifacts:
|
|
841
|
+
print("\n[4/4] Downloading compressed execution logs from cloud...")
|
|
842
|
+
local_log_dir = Path(f"logs/{args.tc}")
|
|
843
|
+
local_log_dir.mkdir(parents=True, exist_ok=True)
|
|
844
|
+
archive_path = local_log_dir / f"{args.tc}_{session_id}_logs.tar.gz"
|
|
845
|
+
|
|
846
|
+
archive_success = client.download_logs_archive(session_id, archive_path)
|
|
847
|
+
if archive_success and archive_path.is_file() and archive_path.stat().st_size > 0:
|
|
848
|
+
compressed_size = archive_path.stat().st_size
|
|
849
|
+
uncompressed_size = 0
|
|
850
|
+
extracted_files = []
|
|
851
|
+
import tarfile
|
|
852
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
853
|
+
for member in tar.getmembers():
|
|
854
|
+
if member.isfile():
|
|
855
|
+
uncompressed_size += member.size
|
|
856
|
+
extracted_files.append((member.name, member.size))
|
|
857
|
+
tar.extractall(path=local_log_dir)
|
|
858
|
+
|
|
859
|
+
archive_path.unlink(missing_ok=True)
|
|
860
|
+
ratio = (1.0 - (compressed_size / max(uncompressed_size, 1))) * 100
|
|
861
|
+
comp_mb = compressed_size / (1024 * 1024)
|
|
862
|
+
uncomp_mb = uncompressed_size / (1024 * 1024)
|
|
863
|
+
print(f" Downloaded {comp_mb:.2f} MB compressed (uncompressed {uncomp_mb:.2f} MB, {ratio:.1f}% bandwidth saved)")
|
|
864
|
+
print(f" Extracted {len(extracted_files)} files to: {local_log_dir}/")
|
|
865
|
+
for fname, fsize in extracted_files:
|
|
866
|
+
sz_str = f"{fsize / (1024*1024):.1f} MB" if fsize >= 1024 * 1024 else f"{fsize / 1024:.1f} KB"
|
|
867
|
+
print(f" - {fname:<32} ({sz_str})")
|
|
868
|
+
else:
|
|
869
|
+
# Fallback to individual artifact downloads
|
|
870
|
+
artifacts = client.list_artifacts(session_id)
|
|
871
|
+
if artifacts:
|
|
872
|
+
for item in artifacts:
|
|
873
|
+
fname = item.get("name")
|
|
874
|
+
if fname and not fname.endswith(".tar.gz"):
|
|
875
|
+
dest = local_log_dir / fname
|
|
876
|
+
if client.download_artifact(session_id, fname, dest):
|
|
877
|
+
print(f" Downloaded artifact to: {dest}")
|
|
878
|
+
else:
|
|
879
|
+
local_runner_log = local_log_dir / "tc_runner.log"
|
|
880
|
+
if client.download_artifact(session_id, "tc_runner.log", local_runner_log):
|
|
881
|
+
print(f" Downloaded runner log to: {local_runner_log}")
|
|
882
|
+
local_ue_log = local_log_dir / "ttcn3_ue.log"
|
|
883
|
+
if client.download_artifact(session_id, "ttcn3_ue.log", local_ue_log):
|
|
884
|
+
print(f" Downloaded UE simulator log to: {local_ue_log}")
|
|
885
|
+
|
|
886
|
+
print("=========================================================================\n")
|
|
887
|
+
return 0 if verdict == "PASS" else 1
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def main():
|
|
891
|
+
default_endpoint = os.environ.get("TTCN3_CLOUD_ENDPOINT")
|
|
892
|
+
if not default_endpoint or "ttcn-cloud.hyper-wireless.com" in default_endpoint:
|
|
893
|
+
default_endpoint = "https://cloud-api.hyper-wireless.com"
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
common_parser = argparse.ArgumentParser(add_help=False)
|
|
897
|
+
common_parser.add_argument("--endpoint", default=default_endpoint, help=f"Cloud service URL (default: {default_endpoint})")
|
|
898
|
+
common_parser.add_argument("--token", default=os.environ.get("TTCN3_CLOUD_API_KEY", ""), help="API Bearer Token")
|
|
899
|
+
common_parser.add_argument("--cf-client-id", default=os.environ.get("CF_ACCESS_CLIENT_ID", ""), help="Cloudflare Access Client ID")
|
|
900
|
+
common_parser.add_argument("--cf-client-secret", default=os.environ.get("CF_ACCESS_CLIENT_SECRET", ""), help="Cloudflare Access Client Secret")
|
|
901
|
+
|
|
902
|
+
parser = argparse.ArgumentParser(description="TTCN-3 Cloud Runner CLI for UE Testing", parents=[common_parser])
|
|
903
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
904
|
+
|
|
905
|
+
# Command: suites
|
|
906
|
+
subparsers.add_parser("suites", parents=[common_parser], help="List available test suites in the cloud")
|
|
907
|
+
|
|
908
|
+
# Command: health
|
|
909
|
+
subparsers.add_parser("health", parents=[common_parser], help="Check health and slot availability")
|
|
910
|
+
|
|
911
|
+
# Command: testcases
|
|
912
|
+
tc_parser = subparsers.add_parser("testcases", parents=[common_parser], help="List test cases for a suite")
|
|
913
|
+
tc_parser.add_argument("--suite", default="NR5GC_IWD_25wk50", help="Suite version")
|
|
914
|
+
|
|
915
|
+
# Command: run
|
|
916
|
+
run_parser = subparsers.add_parser("run", parents=[common_parser], help="Run a test case in the cloud")
|
|
917
|
+
run_parser.add_argument("--tc", required=True, help="Testcase name (e.g. TC_6_1_1_2_NR5GC)")
|
|
918
|
+
run_parser.add_argument("--suite", default=None, help="Suite version (defaults to active suite)")
|
|
919
|
+
run_parser.add_argument("--timeout", type=int, default=None, help="Test execution timeout in seconds (default: 600s [10 mins] for cloud UE, 900s [15 mins] for local/remote UE)")
|
|
920
|
+
run_parser.add_argument("--loglevel", default="info", help="Log level")
|
|
921
|
+
run_parser.add_argument("--cloud-ue", action="store_true", default=True, help="Execute with headless UE (ttcn3_dut + SoftSIM) colocated in cloud (default: True, high speed)")
|
|
922
|
+
run_parser.add_argument("--mode", choices=["cloud_ue", "remote_ue", "no_ue"], default=None, help="Explicit execution mode")
|
|
923
|
+
run_parser.add_argument("--timer-mode", "--speed-mode", dest="timer_mode", choices=["adaptive", "accurate", "balanced", "fast"], default="adaptive", help="Simulation timer mode for lockstep execution: 'adaptive' (dynamic auto-gearing up to 5000ms Gear 3, default), 'accurate' (100ms steps, high precision), 'balanced' (500ms steps), 'fast' (2000ms steps, speed-optimized for UE over WAN/internet)")
|
|
924
|
+
run_parser.add_argument("--timer-step-ms", "--max-step-ms", dest="timer_step_ms", type=int, default=None, help="Explicit max simulated step size in milliseconds (e.g. 100, 500, 1000, 2000). Overrides --timer-mode preset.")
|
|
925
|
+
run_parser.add_argument("--local-port", type=int, default=None, help="Local TCP relay listening port (for remote_ue mode)")
|
|
926
|
+
run_parser.add_argument("--srsue", action="store_true", help="Launch local srsRAN_5G ttcn3_dut (selects remote_ue mode)")
|
|
927
|
+
run_parser.add_argument("--rat", default="nr", help="RAT for srsue (nr, lte)")
|
|
928
|
+
run_parser.add_argument("--raw-logs", action="store_true", help="Stream raw unformatted log lines instead of clean progress view")
|
|
929
|
+
run_parser.add_argument("--download-artifacts", action="store_true", default=True, help="Download execution logs")
|
|
930
|
+
|
|
931
|
+
args = parser.parse_args()
|
|
932
|
+
|
|
933
|
+
# Ensure flags passed before or after subcommands are preserved
|
|
934
|
+
for i, arg in enumerate(sys.argv):
|
|
935
|
+
if arg == "--endpoint" and i + 1 < len(sys.argv):
|
|
936
|
+
args.endpoint = sys.argv[i + 1]
|
|
937
|
+
elif arg.startswith("--endpoint="):
|
|
938
|
+
args.endpoint = arg.split("=", 1)[1]
|
|
939
|
+
elif arg == "--cf-client-id" and i + 1 < len(sys.argv):
|
|
940
|
+
args.cf_client_id = sys.argv[i + 1]
|
|
941
|
+
elif arg.startswith("--cf-client-id="):
|
|
942
|
+
args.cf_client_id = arg.split("=", 1)[1]
|
|
943
|
+
elif arg == "--cf-client-secret" and i + 1 < len(sys.argv):
|
|
944
|
+
args.cf_client_secret = sys.argv[i + 1]
|
|
945
|
+
elif arg.startswith("--cf-client-secret="):
|
|
946
|
+
args.cf_client_secret = arg.split("=", 1)[1]
|
|
947
|
+
|
|
948
|
+
if not args.command or args.command == "run":
|
|
949
|
+
if not hasattr(args, "tc") or not args.tc:
|
|
950
|
+
parser.print_help()
|
|
951
|
+
sys.exit(1)
|
|
952
|
+
sys.exit(run_command_handler(args))
|
|
953
|
+
|
|
954
|
+
client = CloudClient(
|
|
955
|
+
endpoint=args.endpoint,
|
|
956
|
+
api_key=args.token,
|
|
957
|
+
cf_client_id=args.cf_client_id,
|
|
958
|
+
cf_client_secret=args.cf_client_secret,
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
if args.command == "health":
|
|
962
|
+
h = client.get_health()
|
|
963
|
+
print(json.dumps(h, indent=2))
|
|
964
|
+
print()
|
|
965
|
+
|
|
966
|
+
elif args.command == "suites":
|
|
967
|
+
suites = client.get_suites()
|
|
968
|
+
print(f"\nAvailable Cloud Test Suites ({len(suites)} total):")
|
|
969
|
+
print(f"{'SUITE NAME':<26} {'ACTIVE':<8} {'RUNNER AVAILABLE':<18} {'TESTCASES':<10}")
|
|
970
|
+
print("-" * 65)
|
|
971
|
+
for s in suites:
|
|
972
|
+
active_str = "[*]" if s["is_active"] else " "
|
|
973
|
+
runner_str = "YES" if s["has_runner"] else "NO"
|
|
974
|
+
tc_count = str(s["testcase_count"]) if s["testcase_count"] is not None else "-"
|
|
975
|
+
print(f"{s['name']:<26} {active_str:<8} {runner_str:<18} {tc_count:<10}")
|
|
976
|
+
print()
|
|
977
|
+
|
|
978
|
+
elif args.command == "testcases":
|
|
979
|
+
tcs = client.get_testcases(args.suite)
|
|
980
|
+
print(f"\nTest Cases for Suite '{args.suite}' ({len(tcs)} total):")
|
|
981
|
+
print(f"{'TESTCASE NAME':<28} {'STATUS':<12} {'CONDITION':<12} {'DESCRIPTION'}")
|
|
982
|
+
print("-" * 80)
|
|
983
|
+
for tc in tcs[:50]:
|
|
984
|
+
status = "Supported" if tc["supported"] else "Not Supported"
|
|
985
|
+
cond = tc.get("condition") or "-"
|
|
986
|
+
desc = tc.get("purpose") or ""
|
|
987
|
+
print(f"{tc['name']:<28} {status:<12} {cond:<12} {desc[:40]}")
|
|
988
|
+
if len(tcs) > 50:
|
|
989
|
+
print(f"... and {len(tcs) - 50} more test cases.")
|
|
990
|
+
print()
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
if __name__ == "__main__":
|
|
994
|
+
main()
|