[Test] Automatically clean up audio files generated from requests, and realtime invalid-param coverage (#4294)
Signed-off-by: wangyu <410167048@qq.com> Signed-off-by: [Your Name] <your.email@example.com>
This commit is contained in:
1 parent
57ea047acf
commit
1b2ab23586
4 files changed
+107
-15
No files matched your search
@@ -268,6 +268,28 @@ Before entering specific testing levels, the project establishes two common spec
|
||||
1. ***PR Checklist ([Tests Style](../ci/tests_style.md))***: This template defines the self-check items that must be completed before submitting a code review (Pull Request). It ensures that each code change meets basic requirements such as code style, dependency updates, and documentation synchronization before entering the automated testing pipeline, serving as the first manual line of defense for quality assurance.
|
||||
2. ***CI Failure Explanation ([CI Failures](../ci/failures.md))***: This document archives and explains common failure patterns in the Continuous Integration (CI) pipeline, error log interpretation, and preliminary troubleshooting steps. It helps developers and testers quickly diagnose the causes of automated test failures, improving problem-solving efficiency.
|
||||
|
||||
### Test helper environment variables
|
||||
|
||||
Some shared helpers under `tests/helpers/` honor optional environment variables for local debugging. These are **not** set in CI by default.
|
||||
|
||||
| Variable | Accepted values | Description |
|
||||
| -------- | --------------- | ----------- |
|
||||
| `VLLM_OMNI_KEEP_REQUEST_MEDIA` | `1`, `true`, `yes` (case-insensitive) | When enabled, temporary WAV files created by `tests.helpers.media.convert_audio_bytes_to_text` are **not** deleted when the pytest process exits. By default, each call writes a unique file under the system temp directory via `tempfile.mkstemp` and registers `atexit` cleanup. Use this when debugging audio output validation (Whisper transcription, keyword checks, text–audio similarity). The saved path is logged as `audio data is saved: <path>`. |
|
||||
|
||||
Example (Linux / macOS):
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_KEEP_REQUEST_MEDIA=1
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio
|
||||
```
|
||||
|
||||
Example (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:VLLM_OMNI_KEEP_REQUEST_MEDIA = "1"
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -k test_mix_to_text_audio
|
||||
```
|
||||
|
||||
## Chapter 1: L1 & L2 Level Testing - Unit Testing and Basic End-to-End Verification
|
||||
|
||||
### 1.1 Testing Purpose
|
||||
@@ -520,6 +542,8 @@ L3 level testing executes after code is merged into the main branch. Its core pu
|
||||
|
||||
**Implicit Validation**: The `send_omni_request` and `send_diffusion_request` methods internally includes validation logic dynamically selected based on the --run-level parameter: core_model performs basic validation, while advanced_model and full_model perform deep validation.
|
||||
|
||||
**Audio output debugging**: Deep validation may transcribe returned audio via `convert_audio_bytes_to_text` (Whisper). If an audio keyword or text–audio similarity assertion fails, set `VLLM_OMNI_KEEP_REQUEST_MEDIA=1` before running pytest to keep the intermediate WAV files for inspection (see [Test helper environment variables](#test-helper-environment-variables)).
|
||||
|
||||
- ***Run Command (L3 merge)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py -m advanced_model --run-level=advanced_model`
|
||||
|
||||
- ***Run Command (L4 nightly expansion)***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}_expansion.py -m full_model --run-level=full_model`
|
||||
|
||||
@@ -198,19 +198,78 @@ def test_video_chat_stream_invalid_requests(
|
||||
# WS /v1/realtime
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Resolved in ``test_realtime_invalid_requests`` (skip ``session.created`` handshake).
|
||||
_REALTIME_WS_MODEL_MISMATCH = object()
|
||||
_REALTIME_WS_INVALID_AUDIO_APPEND = object()
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize(
|
||||
"send_frames_spec, ws_error_code, err_message",
|
||||
[
|
||||
pytest.param("{not-json", "invalid_json", "Invalid JSON", id="invalid_json"),
|
||||
pytest.param(
|
||||
json.dumps({"type": "session.update"}),
|
||||
"invalid_event",
|
||||
("model", "Missing required field"),
|
||||
id="session_update_missing_model",
|
||||
),
|
||||
pytest.param(
|
||||
_REALTIME_WS_MODEL_MISMATCH,
|
||||
"model_not_found",
|
||||
("model", "does not exist"),
|
||||
id="session_update_model_mismatch",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"type": "input_audio_buffer.commit", "final": False}),
|
||||
"model_not_validated",
|
||||
("session.update", "validate the model"),
|
||||
id="commit_without_model_validation",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"type": "unknown.event"}),
|
||||
"unknown_event",
|
||||
"Unknown event type",
|
||||
id="unknown_event_type",
|
||||
),
|
||||
pytest.param(
|
||||
_REALTIME_WS_INVALID_AUDIO_APPEND,
|
||||
"invalid_audio",
|
||||
"Invalid audio data",
|
||||
id="invalid_audio_append",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
|
||||
def test_realtime_rejected_when_async_chunk_enabled(
|
||||
def test_realtime_invalid_requests(
|
||||
omni_server: OmniServer,
|
||||
openai_client: OpenAIClientHandler,
|
||||
send_frames_spec: Any,
|
||||
ws_error_code: str,
|
||||
err_message: str | tuple[str, ...],
|
||||
) -> None:
|
||||
if send_frames_spec is _REALTIME_WS_MODEL_MISMATCH:
|
||||
send_frames = json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"model": "this-model-is-not-served-on-this-instance",
|
||||
}
|
||||
)
|
||||
elif send_frames_spec is _REALTIME_WS_INVALID_AUDIO_APPEND:
|
||||
send_frames = [
|
||||
json.dumps({"type": "session.update", "model": omni_server.model}),
|
||||
json.dumps({"type": "input_audio_buffer.append", "audio": "not-valid-base64!!!"}),
|
||||
]
|
||||
else:
|
||||
send_frames = send_frames_spec
|
||||
openai_client.send_realtime_ws_request(
|
||||
{
|
||||
"send_frames": send_frames,
|
||||
"timeout": 120,
|
||||
"ws_max_size": None,
|
||||
"ws_skip_types": ["session.created"],
|
||||
"ws_json_type": "error",
|
||||
"ws_error_code": "unsupported",
|
||||
"err_message": "async_chunk",
|
||||
"ws_error_code": ws_error_code,
|
||||
"err_message": err_message,
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Synthetic media generation and media/text utilities for tests."""
|
||||
|
||||
import atexit
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import gc
|
||||
@@ -14,7 +15,6 @@ import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -683,7 +683,10 @@ def convert_audio_file_to_text(output_path: str) -> str:
|
||||
|
||||
|
||||
def convert_audio_bytes_to_text(raw_bytes: bytes) -> str:
|
||||
output_path = f"./test_{uuid.uuid4().hex}.wav"
|
||||
output_fd, output_path = tempfile.mkstemp(prefix="test_", suffix=".wav")
|
||||
os.close(output_fd)
|
||||
if os.environ.get("VLLM_OMNI_KEEP_REQUEST_MEDIA", "").lower() not in ("1", "true", "yes"):
|
||||
atexit.register(Path(output_path).unlink, missing_ok=True)
|
||||
data, samplerate = sf.read(io.BytesIO(raw_bytes))
|
||||
sf.write(output_path, data, samplerate, format="WAV", subtype="PCM_16")
|
||||
print(f"audio data is saved: {output_path}")
|
||||
|
||||
+16
-10
@@ -1473,6 +1473,8 @@ class OpenAIClientHandler:
|
||||
|
||||
- ``send_frames``: optional ``str`` or sequence of ``str`` raw WebSocket text frames (omit when the server
|
||||
speaks first, e.g. ``/v1/realtime`` rejection path).
|
||||
- ``ws_skip_types``: optional event ``type`` strings to ignore while waiting for the first matching frame
|
||||
(e.g. ``["session.created"]`` on ``/v1/realtime``).
|
||||
- ``timeout``: seconds to wait for the first inbound text frame (default ``120``).
|
||||
- ``ws_max_size``: passed through as ``max_size`` to :func:`websockets.connect` when the key is present.
|
||||
"""
|
||||
@@ -1486,6 +1488,7 @@ class OpenAIClientHandler:
|
||||
|
||||
timeout = float(cfg.get("timeout", 120.0))
|
||||
uri = self._build_ws_url(path)
|
||||
skip_types = set(cfg.get("ws_skip_types") or [])
|
||||
|
||||
connect_kw: dict[str, Any] = {}
|
||||
if "ws_max_size" in cfg:
|
||||
@@ -1497,16 +1500,19 @@ class OpenAIClientHandler:
|
||||
async with websockets.connect(uri, **connect_kw) as ws:
|
||||
for frame in frames:
|
||||
await ws.send(frame)
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
||||
if not isinstance(raw, str):
|
||||
raise AssertionError(f"Expected JSON text frame from {uri}, got {type(raw).__name__}")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AssertionError(f"Expected JSON text frame from {uri}, body={raw[:500]!r}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise AssertionError(f"Expected JSON object from {uri}, got {type(data).__name__}")
|
||||
return WebSocketJsonResponse(json_body=data)
|
||||
while True:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
||||
if not isinstance(raw, str):
|
||||
raise AssertionError(f"Expected JSON text frame from {uri}, got {type(raw).__name__}")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AssertionError(f"Expected JSON text frame from {uri}, body={raw[:500]!r}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise AssertionError(f"Expected JSON object from {uri}, got {type(data).__name__}")
|
||||
if skip_types and data.get("type") in skip_types:
|
||||
continue
|
||||
return WebSocketJsonResponse(json_body=data)
|
||||
|
||||
resp = asyncio.run(_recv_first_json_object())
|
||||
_run_ws_expectations_from_request_config(cfg, resp)
|
||||
|
||||
Reference in new issue
Block a user