Files
openobserve/tests/api-testing/helpers/sourcemap_helpers.py
Shrinath RaoandClaude Opus 4.7 61aa772d57 test: revamp api-testing suite (framework + 16 file rewrites + CI matrix) (#12372)
## Summary

Comprehensive rework of the `tests/api-testing/` suite. 30+ commits
implementing all 6 phases of the [revamp
plan](../../tests/ui-testing/MD_Files/api%20tests%20revamp/API_TESTS_REVAMP.md).

**Before → After:**

| | Baseline | Final | Δ |
|---|---|---|---|
| passed | 421 | **443** | **+22 ✓** |
| failed | 3 | **2** | -1 (only same pre-existing) |
| skipped | 53 | 39 | -14 (unskipped dead tests now run) |
| xfailed | 1 | 2 | +1 (new visible backend-bug signal) |
| runtime | 588s | **387s** | **-201s (34% faster)** |

**Zero tests that passed in baseline are now failing.** The 2 remaining
failures are the exact pre-existing ones
(`test_enrichment_table_join.py` backend panic + `test_workflow.py` SMTP
env-miss).

## What's in the diff

- **New `support/` framework** — `OpenObserveClient`, endpoint wrappers
(`alerts`, `dashboards`, `streams`, `search`, etc.), `wait_until()`,
payload factories, pytest fixtures, SSE parser. 14 framework selfcheck
tests.
- **16 of 16 OLD-tier test files rewritten** — using the new framework,
unique-suffix names, body validation, `wait_until` instead of
`time.sleep`, parametrize where appropriate.
- **29 files moved into 12 feature folders** mirroring the UI suite
layout
(`tests/{alerts,dashboards,ingest,orgs,pipelines,regression,rum,search,streams,users,workflow}/`).
- **−6,500 LOC net** (copy-paste + dead code removal; new framework adds
back ~1,500).
- **Ruff config** in pyproject.toml: T201 ban on `print()`, PT pytest
rules, B bugbear, UP pyupgrade.

## CI workflow changes

- New job `api_regression_tests`: `strategy.matrix` over the 4 env-flag
combinations (UTF8_VIEW × JOIN_MATCH_ONE) — now runs in parallel instead
of as 4 sequential server reboots in the main job.
- New job `api_fuzz_tests`: schemathesis OpenAPI-driven fuzz against
`/api-doc/openapi.json` (214 endpoints tested). Informational
(`continue-on-error: true`). Each run surfaces **7–10 distinct 5xx
panics** (e.g., empty `sql` body returning 500 instead of 400, traces
endpoints crashing on unescaped path params that get interpolated into
SQL). Filed to backend team for separate triage.
- Server `o2_*.log` files uploaded as artifacts on failure (was:
silently lost).
- `api_tests_summary` now requires BOTH integration + regression to
pass.

### CI infrastructure fixes

A short hardening pass landed after the initial CI run surfaced several
issues:

- **pytest-sugar count inflation** — dropped `--force-sugar` from both
pytest invocations. The flag double-registered sugar on top of the
auto-loaded entry point, inflating the CI test counter ~3x (display
showed 1364 when collection was 486). Counter is now honest.
- **schemathesis 4.x flag migration** — `--base-url` → `-u`,
`--hypothesis-max-examples` → `-n`, `--hypothesis-deadline` →
`--request-timeout`, `--data-generation-method` → `-m`, dropped
`--auth-type basic`. The fuzz job was failing immediately on flag-parse
before this.
- **Regression matrix binary path** — used `${{ github.workspace
}}/target/debug/openobserve` so the absolute path resolves correctly
under `working-directory: tests/api-testing/` (was: every matrix job
failed with "No such file or directory").
- **`permissions: contents: read`** added to all 4 workflow jobs —
closes CodeQL alert #369.
- **Removed redundant server boot** in `api_integration_tests` (leftover
from main's structure after the regression matrix split; was silently
failing to bind to :5080 and getting masked by the first server).

## Backend bugs surfaced (for backend team to track)

The rework uncovered and visibly flagged these as `xfail`/documented
test comments:

1. `DELETE /api/organizations/{id}` returns 404 unconditionally even for
just-created orgs — endpoint appears unimplemented.
2. `test_dashboard_missing_required_fields` triggers backend panic
(returns 500 instead of 400/422).
3. JS `getEnrichmentTableData()` restricted to `_meta` org AND not
exposed in the `/functions/test` endpoint runtime — `ReferenceError`
even with explicit `enrichment_tables: [...]`.
4. Bulk API silently sanitizes invalid `_index` values (`****` → `_`)
without flagging — user data lands in unexpected index.
5. `AVG` of a single value drifts by ~1e-14 (likely float reduction
order; documented tolerance in `test_advanced_sql.py`).
6. Org name validation rejects hyphens (only
`A-Z/a-z/0-9/spaces/underscores`) — not in spec.

Plus 7–10 additional 5xx panics surfaced per fuzz run (input validation
gaps — separate triage list in fuzz job logs).

## Test bugs uncovered + fixed

- `test_pipeline.py`: had two function definitions named
`test_pipeline_creation_and_action_with_schedule` — Python's last-wins
meant the first test's body never executed for years.
- `test_alertsV2.py`: `assert alert_disabled == "False"` was indented
inside an `else:` branch that never executed because the response is
always a dict; AND `enabled` is a bool, not the string `"False"`. Two
layers of broken.
- `test_bulk.py`: variable typo (`valid_bulk_payload` reused on the POST
call) made the invalid-index test silently duplicate the valid case.
- `test_organisations.py`: `test_create_organization` used pre-pytest-4
`yield`-style fixtures — silently ignored by pytest 8.
- `test_js_result_array.py`: 8 tests skipped with `"Temporarily disabled
- failing test"` — the actual cause was that JS functions are restricted
to `_meta` org. One-line fix unskipped all 8.
- `test_fuzzy.py`: 4 tests `@pytest.mark.skip`-ed because they used a
1-minute time window against older static test data → 0 hits → flake →
skip. Wider window unskips them.
- 547 LOC of zombie commented-out test blocks deleted (15 commented `#
def test_` definitions, including one orphan case where the `def` was
commented but the indented body wasn't).

## Folder layout

```
tests/api-testing/
├── support/                    # NEW framework
│   ├── client.py               # OpenObserveClient
│   ├── endpoints/              # 7 per-resource wrappers
│   ├── factories.py            # unique_name, time_window, *_payload
│   ├── wait.py                 # wait_until + WaitTimeout
│   ├── sse.py                  # read_sse_response
│   └── fixtures.py             # client, temp_user_email, temp_stream_name, ...
├── helpers/                    # Non-test shared code (was scattered in tests/)
│   ├── sourcemap_helpers.py
│   └── workflow/               # workflow chain + pages/ live here now
├── fixtures/
│   └── rum/                    # Standalone RUM ingest scripts (was in tests/)
└── tests/
    ├── alerts/                 # 3 files
    ├── dashboards/             # 4 files
    ├── ingest/                 # 4 files
    ├── orgs/                   # 2 files
    ├── pipelines/              # 10 files
    ├── regression/             # 2 files (env-flag matrix targets)
    ├── rum/                    # 3 files
    ├── search/                 # 8 files
    ├── streams/                # 1 file
    ├── users/                  # 1 file
    ├── workflow/               # 3 files
    ├── conftest.py
    └── test_framework_selfcheck.py
```

## Test plan

- [x] `rye run pytest --collect-only` — 486 tests discovered
- [x] Full default-set run (`--ignore=tests/regression`) — 443 pass / 2
fail / 39 skip / 2 xfail in 387s
- [x] `rye run pytest -m framework` — 14/14 selfcheck tests pass
- [x] `rye run ruff check support/ tests/test_framework_selfcheck.py` —
clean
- [x] CI run on this PR — `api_integration_tests` + all 4
`api_regression_tests` matrix jobs green
- [x] CI run — `api_fuzz_tests` runs cleanly (informational; surfaces
7–10 unique 5xx panics per run)

## Note on test count

The `test_streaming.py` rewrite folded 7 "tests" (`test_ingest_data`,
`test_ingest_join`, `test_disable_streaming`,
`test_update_max_query_range`, `test_enable_streaming`,
`test_delete_stream`, `test_delete_stream_join`) into pytest fixtures.
Each only checked `status_code == 200` of a setup/teardown operation —
they were imperative steps masquerading as tests. The fixtures still
perform the same operations and assert their success; they're just no
longer counted as discrete pytest items. **Functional coverage is
identical.**

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 07:19:48 +00:00

437 lines
13 KiB
Python

"""
Sourcemap Test Helpers for Pytest
Utility functions for sourcemap API testing.
"""
import os
import subprocess
import time
import signal
import logging
import json
from pathlib import Path
logger = logging.getLogger(__name__)
# Path to static fixtures (pre-built sourcemaps)
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "sourcemaps"
# Path to test app (deprecated - tests now use static fixtures)
TEST_APP_DIR = Path(__file__).parent.parent.parent / "ui-testing" / "MD_Files" / "Sourcemaps" / "o2-sourcemap-test-app"
def load_static_sourcemaps(config=None):
"""
Load pre-built sourcemaps from fixtures directory.
This replaces the dynamic build approach - tests use static sourcemaps
committed to the repository instead of building the test app.
Args:
config (dict): Configuration options (service, env, version, org)
Returns:
dict: Build result compatible with test expectations
"""
# Default config
if config is None:
config = {}
service = config.get('service', 'o2-sourcemap-test-app')
env = config.get('env', 'testing')
version = config.get('version', '1.0.0-static')
org = config.get('org', 'default')
logger.info(f"Loading static sourcemaps: service={service}, env={env}, version={version}")
# Load hashes from JSON
hashes_file = FIXTURES_DIR / "hashes.json"
if not hashes_file.exists():
raise FileNotFoundError(f"Hashes file not found: {hashes_file}")
with open(hashes_file, 'r') as f:
hashes = json.load(f)
logger.info(f"Loaded content hashes: {hashes}")
# Path to sourcemaps ZIP
sourcemaps_zip = FIXTURES_DIR / "sourcemaps.zip"
if not sourcemaps_zip.exists():
raise FileNotFoundError(f"Sourcemaps ZIP not found: {sourcemaps_zip}")
logger.info(f"Using sourcemaps ZIP: {sourcemaps_zip}")
return {
'app_dir': str(FIXTURES_DIR),
'dist_path': str(FIXTURES_DIR),
'hashes': hashes,
'sourcemaps_zip': str(sourcemaps_zip),
'config': {'service': service, 'env': env, 'version': version, 'org': org}
}
def build_test_app(rum_token, config=None):
"""
Build the sourcemap test app with updated RUM SDK configuration.
Args:
rum_token (str): RUM token from API
config (dict): Build configuration options
Returns:
dict: Build result with hashes and paths
"""
if config is None:
config = {}
site = config.get('site', 'localhost:5080')
insecure_http = config.get('insecureHTTP', True)
service = config.get('service', 'o2-sourcemap-test-app')
env = config.get('env', 'testing')
version = config.get('version', '1.0.0-api-test')
org = config.get('org', 'default')
logger.info(f"Building test app: service={service}, env={env}, version={version}")
# Path to config file
config_path = TEST_APP_DIR / "src" / "index.js"
# Read current config
with open(config_path, 'r') as f:
config_content = f.read()
# Update SDK config
import re
config_content = re.sub(r"clientToken:\s*['\"][^'\"]*['\"]", f"clientToken: '{rum_token}'", config_content)
config_content = re.sub(r"site:\s*['\"][^'\"]*['\"]", f"site: '{site}'", config_content)
config_content = re.sub(r"insecureHTTP:\s*(?:true|false)", f"insecureHTTP: {str(insecure_http).lower()}", config_content)
config_content = re.sub(r"service:\s*['\"][^'\"]*['\"]", f"service: '{service}'", config_content)
config_content = re.sub(r"env:\s*['\"][^'\"]*['\"]", f"env: '{env}'", config_content)
config_content = re.sub(r"version:\s*['\"][^'\"]*['\"]", f"version: '{version}'", config_content)
config_content = re.sub(r"organizationIdentifier:\s*['\"][^'\"]*['\"]", f"organizationIdentifier: '{org}'", config_content)
# Write updated config
with open(config_path, 'w') as f:
f.write(config_content)
logger.info("Updated SDK configuration")
# Build app
logger.info("Running npm run build...")
result = subprocess.run(
['npm', 'run', 'build'],
cwd=TEST_APP_DIR,
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
logger.error(f"Build failed: {result.stderr}")
raise Exception(f"Build failed: {result.stderr}")
logger.info("Build completed successfully")
# Extract content hashes
dist_path = TEST_APP_DIR / "dist"
dist_files = os.listdir(dist_path)
hashes = {
'main': extract_hash(dist_files, 'main', '.js'),
'lazy_module': extract_hash(dist_files, 'lazy-module', '.js'),
'profiler': extract_hash(dist_files, 'profiler', '.js'),
'recorder': extract_hash(dist_files, 'recorder', '.js')
}
logger.info(f"Extracted hashes: {hashes}")
# Create sourcemaps ZIP
sourcemaps_zip = dist_path / "sourcemaps.zip"
# Remove old ZIP if exists
if sourcemaps_zip.exists():
sourcemaps_zip.unlink()
subprocess.run(
['zip', 'sourcemaps.zip'] + [f for f in dist_files if f.endswith('.map')],
cwd=dist_path,
check=True,
capture_output=True
)
logger.info(f"Created sourcemaps ZIP at {sourcemaps_zip}")
return {
'app_dir': str(TEST_APP_DIR),
'dist_path': str(dist_path),
'hashes': hashes,
'sourcemaps_zip': str(sourcemaps_zip),
'config': {'service': service, 'env': env, 'version': version, 'org': org}
}
def extract_hash(files, prefix, ext):
"""Extract content hash from filename."""
import re
pattern = re.compile(f"{prefix}\\.(.*?)\\{ext}")
for file in files:
if file.startswith(prefix) and file.endswith(ext) and not file.endswith('.map'):
match = pattern.search(file)
if match:
return match.group(1)
raise Exception(f"Could not find file matching {prefix}*{ext}")
def serve_test_app(dist_path, port=8089):
"""
Start HTTP server to serve test app.
Args:
dist_path (str): Path to dist directory
port (int): Port number
Returns:
dict: Server process info
"""
logger.info(f"Starting HTTP server on port {port}...")
# Start Python HTTP server
process = subprocess.Popen(
['python3', '-m', 'http.server', str(port)],
cwd=dist_path,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid # Create new process group
)
# Wait for server to be ready
time.sleep(2)
logger.info(f"HTTP server started (PID: {process.pid})")
return {
'process': process,
'pid': process.pid,
'port': port,
'url': f'http://localhost:{port}'
}
def stop_test_app_server(server_info):
"""Stop the HTTP server."""
if not server_info or 'pid' not in server_info:
logger.warning("No server PID provided, skipping stop")
return
pid = server_info['pid']
logger.info(f"Stopping HTTP server (PID: {pid})...")
try:
# Kill process group
os.killpg(os.getpgid(pid), signal.SIGTERM)
logger.info("HTTP server stopped")
except Exception as e:
logger.warning(f"Failed to stop server: {e}")
try:
# Try direct kill
os.kill(pid, signal.SIGKILL)
except:
pass
# Also kill any processes on the port
port = server_info.get('port', 8089)
try:
# Use list-based args to avoid shell injection
result = subprocess.run(
['lsof', '-ti', f':{port}'],
capture_output=True,
text=True,
check=False
)
if result.stdout.strip():
pids = result.stdout.strip().split('\n')
for pid in pids:
try:
os.kill(int(pid), signal.SIGKILL)
except (ValueError, ProcessLookupError):
pass
except Exception:
pass
def upload_sourcemaps(session, base_url, org_id, zip_path, service, env, version):
"""Upload sourcemaps to OpenObserve."""
logger.info(f"Uploading sourcemaps: service={service}, env={env}, version={version}")
with open(zip_path, 'rb') as f:
files = {'file': ('sourcemaps.zip', f, 'application/zip')}
data = {
'service': service,
'env': env,
'version': version
}
response = session.post(
f"{base_url}api/{org_id}/sourcemaps",
files=files,
data=data
)
logger.info(f"Upload response: {response.status_code}")
if response.status_code not in [200, 201]:
logger.error(f"Upload failed: {response.text}")
raise Exception(f"Upload failed: {response.status_code} - {response.text}")
return response
def list_sourcemaps(session, base_url, org_id, service=None, env=None, version=None):
"""List sourcemaps from OpenObserve."""
params = {}
if service:
params['service'] = service
if env:
params['env'] = env
if version:
params['version'] = version
response = session.get(
f"{base_url}api/{org_id}/sourcemaps",
params=params
)
if response.status_code != 200:
logger.error(f"List failed: {response.text}")
raise Exception(f"List failed: {response.status_code}")
return response.json()
def delete_sourcemaps(session, base_url, org_id, service, env, version):
"""Delete sourcemaps from OpenObserve."""
logger.info(f"Deleting sourcemaps: service={service}, env={env}, version={version}")
params = {
'service': service,
'env': env,
'version': version
}
response = session.delete(
f"{base_url}api/{org_id}/sourcemaps",
params=params
)
logger.info(f"Delete response: {response.status_code}")
return response
def resolve_stacktrace(session, base_url, org_id, service, env, version, stacktrace):
"""Resolve minified stacktrace via sourcemap API."""
logger.info(f"Resolving stacktrace for {service}:{version}")
payload = {
'service': service,
'env': env,
'version': version,
'stacktrace': stacktrace
}
response = session.post(
f"{base_url}api/{org_id}/sourcemaps/stacktrace",
json=payload
)
if response.status_code != 200:
logger.error(f"Resolution failed: {response.text}")
raise Exception(f"Resolution failed: {response.status_code}")
return response.json()
def get_expected_stacktrace(error_type, hashes):
"""Generate expected stacktrace with actual content hashes."""
templates = {
'type_error': f"""TypeError: Cannot read properties of undefined (reading 'profile')
at Ht @ http://localhost:8089/main.{hashes['main']}.js:1:186484
at http://localhost:8089/main.{hashes['main']}.js:1:186818""",
'reference_error': f"""ReferenceError: undeclaredVariableThatDoesNotExist is not defined
at Zt @ http://localhost:8089/main.{hashes['main']}.js:1:186511""",
'range_error': f"""RangeError: Invalid array length
at Kt @ http://localhost:8089/main.{hashes['main']}.js:1:186576""",
'custom_error': f"""PaymentError: Insufficient funds in account
at Jt @ http://localhost:8089/main.{hashes['main']}.js:1:186730""",
'cross_chunk': f"""TypeError: Cannot read properties of null (reading 'timestamp')
at u @ http://localhost:8089/lazy-module.{hashes['lazy_module']}.js:1:131
at http://localhost:8089/main.{hashes['main']}.js:1:186963"""
}
if error_type not in templates:
raise ValueError(f"Unknown error type: {error_type}")
return templates[error_type]
def get_rum_token(session, base_url, org_id):
"""Get RUM token from OpenObserve."""
logger.info("Fetching RUM token...")
response = session.get(f"{base_url}api/{org_id}/rumtoken")
if response.status_code != 200:
logger.error(f"Failed to get RUM token: {response.text}")
raise Exception(f"Failed to get RUM token: {response.status_code}")
data = response.json()
# Use same pattern as ingest_rum_errors.py to handle nested response structure
token = data.get('data', {}).get('rum_token') or data.get('rum_token')
if not token:
logger.error(f"RUM token is empty in response: {data}")
raise Exception("RUM token not found in response")
logger.info(f"RUM token retrieved: {token}")
return token
# Expected resolution results for validation
EXPECTED_RESOLUTIONS = {
'type_error': {
'file': 'src/errors/sync-errors.js',
'line': 10,
'column': 15,
'function': 'profile',
'contains': 'return user.profile.name'
},
'reference_error': {
'file': 'src/errors/sync-errors.js',
'line': 16,
'contains': 'undeclaredVariableThatDoesNotExist'
},
'range_error': {
'file': 'src/errors/sync-errors.js',
'line': 22,
'contains': 'new Array(-1)'
},
'custom_error': {
'file': 'src/errors/sync-errors.js',
'line': 36,
'contains': 'PaymentError'
},
'cross_chunk': {
'file': 'src/errors/lazy-module.js',
'line': 20,
'function': 'triggerLazyModuleError'
}
}