testo-core 1.1.1__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.
- testo_api/__init__.py +3 -0
- testo_api/cycle_execution_manager.py +539 -0
- testo_api/dependencies.py +21 -0
- testo_api/main.py +162 -0
- testo_api/models.py +408 -0
- testo_api/routes/__init__.py +1 -0
- testo_api/routes/ai.py +100 -0
- testo_api/routes/analytics.py +141 -0
- testo_api/routes/cycles.py +214 -0
- testo_api/routes/dashboard.py +121 -0
- testo_api/routes/health.py +46 -0
- testo_api/routes/history.py +149 -0
- testo_api/security.py +77 -0
- testo_core/__init__.py +65 -0
- testo_core/cli/__init__.py +5 -0
- testo_core/cli/app.py +161 -0
- testo_core/cli/cleanup.py +42 -0
- testo_core/cli/commands/__init__.py +6 -0
- testo_core/cli/commands/clean.py +75 -0
- testo_core/cli/commands/config.py +127 -0
- testo_core/cli/commands/config_db.py +140 -0
- testo_core/cli/commands/diff_cli.py +112 -0
- testo_core/cli/commands/doctor.py +161 -0
- testo_core/cli/commands/init_cmd.py +65 -0
- testo_core/cli/commands/plans.py +92 -0
- testo_core/cli/commands/report.py +424 -0
- testo_core/cli/commands/run.py +100 -0
- testo_core/cli/commands/version.py +18 -0
- testo_core/cli/commands/watch.py +135 -0
- testo_core/cli/runner.py +211 -0
- testo_core/cli/ui/__init__.py +5 -0
- testo_core/cli/ui/ci_renderer.py +19 -0
- testo_core/cli/ui/console.py +65 -0
- testo_core/cli/ui/panels.py +128 -0
- testo_core/cli/ui/progress.py +29 -0
- testo_core/cli/ui/renderers.py +178 -0
- testo_core/cli/ui/summary_dashboard.py +345 -0
- testo_core/config/__init__.py +28 -0
- testo_core/config/database_section.py +165 -0
- testo_core/config/errors.py +29 -0
- testo_core/config/loader.py +458 -0
- testo_core/config/resolver.py +95 -0
- testo_core/config/schema.py +117 -0
- testo_core/config/triggers.py +294 -0
- testo_core/engine/__init__.py +30 -0
- testo_core/engine/events.py +46 -0
- testo_core/engine/executor.py +256 -0
- testo_core/engine/exit_codes.py +45 -0
- testo_core/engine/log_buffer.py +144 -0
- testo_core/engine/orchestrator.py +246 -0
- testo_core/engine/result.py +51 -0
- testo_core/frameworks/__init__.py +21 -0
- testo_core/frameworks/base.py +73 -0
- testo_core/frameworks/behave_adapter.py +49 -0
- testo_core/frameworks/behavex_adapter.py +82 -0
- testo_core/frameworks/command_adapter.py +41 -0
- testo_core/frameworks/pytest_adapter.py +121 -0
- testo_core/history/__init__.py +16 -0
- testo_core/history/maintenance.py +46 -0
- testo_core/history/read_model.py +52 -0
- testo_core/history/report_links.py +45 -0
- testo_core/history/snapshots.py +21 -0
- testo_core/history/views.py +122 -0
- testo_core/mascot/__init__.py +422 -0
- testo_core/mascot/__main__.py +30 -0
- testo_core/mascot/export_ts.py +51 -0
- testo_core/mascot/render.py +114 -0
- testo_core/paths.py +102 -0
- testo_core/persistence/__init__.py +23 -0
- testo_core/persistence/backend.py +21 -0
- testo_core/persistence/composite.py +50 -0
- testo_core/persistence/db_backend.py +128 -0
- testo_core/persistence/failure_context.py +112 -0
- testo_core/persistence/health.py +109 -0
- testo_core/persistence/json_backend.py +63 -0
- testo_core/reporting/__init__.py +11 -0
- testo_core/reporting/allure.py +84 -0
- testo_core/reporting/allure_cli.py +212 -0
- testo_core/reporting/allure_results.py +232 -0
- testo_core/reporting/collector.py +144 -0
- testo_core/reporting/entry.py +129 -0
- testo_core/reporting/exporter.py +151 -0
- testo_core/reporting/history_inject.py +76 -0
- testo_core/reporting/integrations.py +232 -0
- testo_core/reporting/junit_import.py +161 -0
- testo_core/reporting/metrics.py +191 -0
- testo_core/reporting/metrics_extractor.py +187 -0
- testo_core/reporting/native_reports.py +277 -0
- testo_core/reporting/paths.py +81 -0
- testo_core/reporting/pyramid_data.py +33 -0
- testo_core/reporting/pyramid_viz.py +120 -0
- testo_core/reporting/report_generator.py +537 -0
- testo_core/reporting/reporters/__init__.py +14 -0
- testo_core/reporting/reporters/allure_reporter.py +137 -0
- testo_core/reporting/reporters/base.py +63 -0
- testo_core/reporting/reporters/extent_builder.py +106 -0
- testo_core/reporting/reporters/extent_reporter.py +61 -0
- testo_core/reporting/reporters/factory.py +122 -0
- testo_core/reporting/reporters/orchestrate.py +93 -0
- testo_core/reporting/reporters/reportportal_client.py +367 -0
- testo_core/reporting/reporters/reportportal_reporter.py +97 -0
- testo_core/reporting/reporters/templates/extent_dashboard.html.j2 +244 -0
- testo_core/reporting/reporters/testbeats_payloads.py +197 -0
- testo_core/reporting/reporters/testbeats_reporter.py +115 -0
- testo_core/reporting/server.py +140 -0
- testo_core/repository/__init__.py +15 -0
- testo_core/repository/adapters.py +137 -0
- testo_core/repository/base.py +62 -0
- testo_core/repository/db.py +31 -0
- testo_core/repository/db_config.py +117 -0
- testo_core/repository/factory.py +115 -0
- testo_core/repository/models.py +87 -0
- testo_core/repository/report_archive_repository.py +90 -0
- testo_core/security/__init__.py +15 -0
- testo_core/security/redaction.py +53 -0
- testo_core/services/__init__.py +86 -0
- testo_core/services/ai/__init__.py +33 -0
- testo_core/services/ai/config.py +54 -0
- testo_core/services/ai/factory.py +18 -0
- testo_core/services/ai/integration_settings.py +66 -0
- testo_core/services/ai/provider_base.py +54 -0
- testo_core/services/ai/providers/__init__.py +4 -0
- testo_core/services/ai/providers/anthropic_provider.py +76 -0
- testo_core/services/ai/providers/openai_provider.py +78 -0
- testo_core/services/ci_provenance.py +84 -0
- testo_core/services/cycle_run.py +355 -0
- testo_core/services/dashboard_service.py +341 -0
- testo_core/services/delta_models.py +61 -0
- testo_core/services/delta_service.py +284 -0
- testo_core/services/failure_analysis_service.py +192 -0
- testo_core/services/failure_context_builder.py +105 -0
- testo_core/services/metrics_service.py +41 -0
- testo_core/services/report_archive.py +160 -0
- testo_core/services/report_archive_diff.py +191 -0
- testo_core/services/report_service.py +121 -0
- testo_core/services/run_snapshot_diff.py +37 -0
- testo_core-1.1.1.dist-info/METADATA +310 -0
- testo_core-1.1.1.dist-info/RECORD +141 -0
- testo_core-1.1.1.dist-info/WHEEL +4 -0
- testo_core-1.1.1.dist-info/entry_points.txt +3 -0
- testo_core-1.1.1.dist-info/licenses/LICENSE +21 -0
testo_api/__init__.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
from uuid import UUID, uuid4
|
|
16
|
+
|
|
17
|
+
from testo_core.config.errors import ConfigDiscoveryError
|
|
18
|
+
from testo_core.config.loader import discover_and_load
|
|
19
|
+
from testo_core.config.resolver import resolve_plan
|
|
20
|
+
from testo_core.config.schema import Defaults, Plan, TestosteroneConfig
|
|
21
|
+
from testo_core.config.triggers import TriggerResult
|
|
22
|
+
from testo_core.services.cycle_run import (
|
|
23
|
+
ADHOC_PLAN_NAME,
|
|
24
|
+
CycleRunOptions,
|
|
25
|
+
CycleRunService,
|
|
26
|
+
single_stage_plan,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
CycleExecutionStatus = Literal["queued", "running", "completed", "failed"]
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# The registry only exists so clients can follow a run while it is live; the durable
|
|
34
|
+
# record of every run is the run history DB. Running executions are always kept;
|
|
35
|
+
# finished ones are kept up to this many (oldest evicted first, minimum 1 so a run's
|
|
36
|
+
# final status can always be read), so memory stays bounded however many runs happen.
|
|
37
|
+
MAX_FINISHED_EXECUTIONS_ENV = "TESTO_MAX_FINISHED_EXECUTIONS"
|
|
38
|
+
DEFAULT_MAX_FINISHED_EXECUTIONS = 200
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _max_finished_from_env() -> int:
|
|
42
|
+
raw = os.environ.get(MAX_FINISHED_EXECUTIONS_ENV)
|
|
43
|
+
if raw is None or not raw.strip():
|
|
44
|
+
return DEFAULT_MAX_FINISHED_EXECUTIONS
|
|
45
|
+
try:
|
|
46
|
+
return max(1, int(raw))
|
|
47
|
+
except ValueError:
|
|
48
|
+
logger.warning(
|
|
49
|
+
"%s=%r is not an integer; using %d",
|
|
50
|
+
MAX_FINISHED_EXECUTIONS_ENV,
|
|
51
|
+
raw,
|
|
52
|
+
DEFAULT_MAX_FINISHED_EXECUTIONS,
|
|
53
|
+
)
|
|
54
|
+
return DEFAULT_MAX_FINISHED_EXECUTIONS
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Resolves (config, plan) on the worker thread, so config errors surface as a failed execution.
|
|
58
|
+
PlanLoader = Callable[[], tuple[TestosteroneConfig, Plan]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class CycleExecutionState:
|
|
63
|
+
execution_id: str
|
|
64
|
+
cycle: str
|
|
65
|
+
created_at: float
|
|
66
|
+
status: CycleExecutionStatus = "queued"
|
|
67
|
+
artifacts_root: Path | None = None
|
|
68
|
+
plan_result_path: Path | None = None
|
|
69
|
+
events_path: Path | None = None
|
|
70
|
+
events_start_offset_bytes: int = 0
|
|
71
|
+
# Where this execution's events end in the (per-cycle, shared) events file; set on finish.
|
|
72
|
+
events_end_offset_bytes: int | None = None
|
|
73
|
+
done: bool = False
|
|
74
|
+
error: str | None = None
|
|
75
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
76
|
+
|
|
77
|
+
def mark_done(self, *, status: CycleExecutionStatus, error: str | None = None) -> None:
|
|
78
|
+
with self.lock:
|
|
79
|
+
self.status = status
|
|
80
|
+
self.error = error
|
|
81
|
+
self.done = True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _NullRenderer:
|
|
85
|
+
wants_streaming = False
|
|
86
|
+
|
|
87
|
+
def handle(self, _event) -> None: # noqa: ANN001
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _NdjsonCycleListener:
|
|
92
|
+
"""Writes the trigger verdict into the cycle's events.ndjson so the SSE stream carries it."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, events_path: Path, *, ci: bool) -> None:
|
|
95
|
+
self._events_path = events_path
|
|
96
|
+
self._ci = ci
|
|
97
|
+
|
|
98
|
+
def trigger_evaluated(self, plan: Plan, result: TriggerResult) -> None:
|
|
99
|
+
if not self._ci:
|
|
100
|
+
return
|
|
101
|
+
_append_ndjson_line(
|
|
102
|
+
self._events_path,
|
|
103
|
+
{
|
|
104
|
+
"event": "cycle_trigger",
|
|
105
|
+
"cycle": plan.name,
|
|
106
|
+
"status": "activated" if result.stimulus else "resting",
|
|
107
|
+
"reason": result.reason,
|
|
108
|
+
"matched": list(result.matched_paths),
|
|
109
|
+
"mode": result.mode,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def trigger_bypassed(self, plan: Plan) -> None:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
def report_archived(self, report_id: UUID | None, *, background: bool) -> None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _append_ndjson_line(path: Path, payload: dict[str, object]) -> None:
|
|
121
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
123
|
+
fh.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=True))
|
|
124
|
+
fh.write("\n")
|
|
125
|
+
fh.flush()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CycleExecutionManager:
|
|
129
|
+
"""
|
|
130
|
+
Manage plan/cycle executions using the modern engine lifecycle:
|
|
131
|
+
|
|
132
|
+
- `discover_and_load` config discovery + `resolve_plan` (a named cycle), or
|
|
133
|
+
`single_stage_plan` (an ad-hoc run of one framework, see `create_adhoc_execution`)
|
|
134
|
+
- `CycleRunService.run()`, the same use case as `testo run`: trigger gate
|
|
135
|
+
(`cycle_trigger` NDJSON event), `orchestrator.run_plan()` writing durable NDJSON
|
|
136
|
+
into `artifacts/<cycle>/events.ndjson`, reporters and the report archive
|
|
137
|
+
|
|
138
|
+
Streaming is done by tailing `events.ndjson` from a recorded byte offset.
|
|
139
|
+
|
|
140
|
+
The registry is bounded: every running execution is kept, plus the last
|
|
141
|
+
`max_finished` finished ones (default from `TESTO_MAX_FINISHED_EXECUTIONS`).
|
|
142
|
+
Older finished executions are evicted and their status/events endpoints answer
|
|
143
|
+
404; their results stay in the run history (`/api/v1/runs`).
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, *, max_finished: int | None = None) -> None:
|
|
147
|
+
self._states: dict[str, CycleExecutionState] = {}
|
|
148
|
+
self._states_lock = threading.Lock()
|
|
149
|
+
self._active_by_cycle: dict[str, str] = {}
|
|
150
|
+
configured = _max_finished_from_env() if max_finished is None else max_finished
|
|
151
|
+
self.max_finished = max(1, configured)
|
|
152
|
+
# Finished execution ids, oldest first: the eviction order.
|
|
153
|
+
self._finished: deque[str] = deque()
|
|
154
|
+
|
|
155
|
+
def create_execution(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
cycle: str,
|
|
159
|
+
config_path: Path | None,
|
|
160
|
+
artifacts_root_override: Path | None = None,
|
|
161
|
+
persist: bool = True,
|
|
162
|
+
force: bool = False,
|
|
163
|
+
fail_fast: bool = False,
|
|
164
|
+
reporter_override: list[str] | None = None,
|
|
165
|
+
report_db: bool = True,
|
|
166
|
+
async_report_db: bool = False,
|
|
167
|
+
workers_override: int | None = None,
|
|
168
|
+
stream: bool = False,
|
|
169
|
+
ci: bool = True,
|
|
170
|
+
) -> CycleExecutionState:
|
|
171
|
+
def load_plan() -> tuple[TestosteroneConfig, Plan]:
|
|
172
|
+
cfg = discover_and_load(config_path=config_path)
|
|
173
|
+
return cfg, resolve_plan(cfg, plan_name=cycle)
|
|
174
|
+
|
|
175
|
+
return self._start(
|
|
176
|
+
cycle=str(cycle),
|
|
177
|
+
load_plan=load_plan,
|
|
178
|
+
artifacts_root_override=artifacts_root_override,
|
|
179
|
+
persist=persist,
|
|
180
|
+
force=force,
|
|
181
|
+
fail_fast=fail_fast,
|
|
182
|
+
reporter_override=reporter_override,
|
|
183
|
+
report_db=report_db,
|
|
184
|
+
async_report_db=async_report_db,
|
|
185
|
+
workers_override=workers_override,
|
|
186
|
+
stream=stream,
|
|
187
|
+
ci=ci,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def create_adhoc_execution(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
framework: str,
|
|
194
|
+
target_repo: Path,
|
|
195
|
+
args: Sequence[str] = (),
|
|
196
|
+
timeout_s: float | None = None,
|
|
197
|
+
extra_env: Mapping[str, str] | None = None,
|
|
198
|
+
config_path: Path | None = None,
|
|
199
|
+
artifacts_root_override: Path | None = None,
|
|
200
|
+
persist: bool = True,
|
|
201
|
+
report_db: bool = True,
|
|
202
|
+
) -> CycleExecutionState:
|
|
203
|
+
"""Run one framework directly, as a one-stage ``adhoc`` cycle.
|
|
204
|
+
|
|
205
|
+
Validates the stage up front (``ConfigValidationError``). Defaults and
|
|
206
|
+
reporters come from the discovered config when there is one.
|
|
207
|
+
"""
|
|
208
|
+
plan = single_stage_plan(
|
|
209
|
+
framework=framework,
|
|
210
|
+
target_repo=target_repo,
|
|
211
|
+
args=args,
|
|
212
|
+
timeout_s=timeout_s,
|
|
213
|
+
extra_env=extra_env,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def load_plan() -> tuple[TestosteroneConfig, Plan]:
|
|
217
|
+
try:
|
|
218
|
+
cfg = discover_and_load(config_path=config_path)
|
|
219
|
+
except ConfigDiscoveryError:
|
|
220
|
+
if config_path is not None:
|
|
221
|
+
raise
|
|
222
|
+
cfg = TestosteroneConfig(version=1, defaults=Defaults())
|
|
223
|
+
return cfg, plan
|
|
224
|
+
|
|
225
|
+
return self._start(
|
|
226
|
+
cycle=ADHOC_PLAN_NAME,
|
|
227
|
+
load_plan=load_plan,
|
|
228
|
+
artifacts_root_override=artifacts_root_override,
|
|
229
|
+
persist=persist,
|
|
230
|
+
force=False,
|
|
231
|
+
fail_fast=False,
|
|
232
|
+
reporter_override=None,
|
|
233
|
+
report_db=report_db,
|
|
234
|
+
async_report_db=False,
|
|
235
|
+
workers_override=None,
|
|
236
|
+
stream=True,
|
|
237
|
+
ci=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _start(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
cycle: str,
|
|
244
|
+
load_plan: PlanLoader,
|
|
245
|
+
artifacts_root_override: Path | None,
|
|
246
|
+
persist: bool,
|
|
247
|
+
force: bool,
|
|
248
|
+
fail_fast: bool,
|
|
249
|
+
reporter_override: list[str] | None,
|
|
250
|
+
report_db: bool,
|
|
251
|
+
async_report_db: bool,
|
|
252
|
+
workers_override: int | None,
|
|
253
|
+
stream: bool,
|
|
254
|
+
ci: bool,
|
|
255
|
+
) -> CycleExecutionState:
|
|
256
|
+
execution_id = str(uuid4())
|
|
257
|
+
state = CycleExecutionState(execution_id=execution_id, cycle=cycle, created_at=time.time())
|
|
258
|
+
|
|
259
|
+
with self._states_lock:
|
|
260
|
+
existing = self._active_by_cycle.get(str(cycle))
|
|
261
|
+
if existing is not None:
|
|
262
|
+
raise RuntimeError(f"cycle {cycle!r} already running (execution_id={existing})")
|
|
263
|
+
self._states[execution_id] = state
|
|
264
|
+
self._active_by_cycle[str(cycle)] = execution_id
|
|
265
|
+
|
|
266
|
+
threading.Thread(
|
|
267
|
+
target=self._run_execution,
|
|
268
|
+
args=(
|
|
269
|
+
state,
|
|
270
|
+
load_plan,
|
|
271
|
+
artifacts_root_override,
|
|
272
|
+
persist,
|
|
273
|
+
force,
|
|
274
|
+
fail_fast,
|
|
275
|
+
reporter_override,
|
|
276
|
+
report_db,
|
|
277
|
+
async_report_db,
|
|
278
|
+
workers_override,
|
|
279
|
+
stream,
|
|
280
|
+
ci,
|
|
281
|
+
),
|
|
282
|
+
daemon=True,
|
|
283
|
+
).start()
|
|
284
|
+
return state
|
|
285
|
+
|
|
286
|
+
def get(self, execution_id: str) -> CycleExecutionState | None:
|
|
287
|
+
with self._states_lock:
|
|
288
|
+
return self._states.get(execution_id)
|
|
289
|
+
|
|
290
|
+
def _run_execution(
|
|
291
|
+
self,
|
|
292
|
+
state: CycleExecutionState,
|
|
293
|
+
load_plan: PlanLoader,
|
|
294
|
+
artifacts_root_override: Path | None,
|
|
295
|
+
persist: bool,
|
|
296
|
+
force: bool,
|
|
297
|
+
fail_fast: bool,
|
|
298
|
+
reporter_override: list[str] | None,
|
|
299
|
+
report_db: bool,
|
|
300
|
+
async_report_db: bool,
|
|
301
|
+
workers_override: int | None,
|
|
302
|
+
stream: bool,
|
|
303
|
+
ci: bool,
|
|
304
|
+
) -> None:
|
|
305
|
+
status: CycleExecutionStatus = "failed"
|
|
306
|
+
error: str | None = "execution interrupted"
|
|
307
|
+
try:
|
|
308
|
+
with state.lock:
|
|
309
|
+
state.status = "running"
|
|
310
|
+
|
|
311
|
+
cfg, plan = load_plan()
|
|
312
|
+
artifacts_root = (
|
|
313
|
+
(artifacts_root_override or cfg.defaults.artifacts_root).expanduser().resolve()
|
|
314
|
+
)
|
|
315
|
+
plan_artifacts = (artifacts_root / plan.name).resolve()
|
|
316
|
+
events_path = plan_artifacts / "events.ndjson"
|
|
317
|
+
plan_result_path = plan_artifacts / "plan_result.json"
|
|
318
|
+
|
|
319
|
+
plan_artifacts.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
start_offset = 0
|
|
321
|
+
try:
|
|
322
|
+
start_offset = int(events_path.stat().st_size) if events_path.exists() else 0
|
|
323
|
+
except OSError:
|
|
324
|
+
start_offset = 0
|
|
325
|
+
|
|
326
|
+
with state.lock:
|
|
327
|
+
state.artifacts_root = artifacts_root
|
|
328
|
+
state.events_path = events_path
|
|
329
|
+
state.plan_result_path = plan_result_path
|
|
330
|
+
state.events_start_offset_bytes = start_offset
|
|
331
|
+
|
|
332
|
+
# Stage subprocesses resolve tools (pytest/behave/...) via PATH. The API server
|
|
333
|
+
# may be launched without an activated venv, so prepend this interpreter's bin
|
|
334
|
+
# dir to guarantee stages run against the same environment as the engine.
|
|
335
|
+
parent_env = dict(os.environ)
|
|
336
|
+
# Note: do not resolve() — in a venv, sys.executable is a symlink into the
|
|
337
|
+
# base interpreter; resolving it would point PATH at the wrong bin dir.
|
|
338
|
+
exe_bin = str(Path(sys.executable).parent)
|
|
339
|
+
path_entries = parent_env.get("PATH", "").split(os.pathsep)
|
|
340
|
+
if exe_bin not in path_entries:
|
|
341
|
+
parent_env["PATH"] = os.pathsep.join([exe_bin, *path_entries])
|
|
342
|
+
|
|
343
|
+
# Same use case as `testo run`; `run_plan` persists events.ndjson and
|
|
344
|
+
# plan_result.json under artifacts/<cycle>/, which the SSE route tails.
|
|
345
|
+
outcome = CycleRunService(listener=_NdjsonCycleListener(events_path, ci=ci)).run(
|
|
346
|
+
cfg=cfg,
|
|
347
|
+
plan=plan,
|
|
348
|
+
renderer=_NullRenderer(),
|
|
349
|
+
options=CycleRunOptions(
|
|
350
|
+
persist=persist,
|
|
351
|
+
force=force,
|
|
352
|
+
fail_fast=fail_fast,
|
|
353
|
+
workers_override=workers_override,
|
|
354
|
+
reporter_override=reporter_override,
|
|
355
|
+
report_db=report_db,
|
|
356
|
+
async_report_db=async_report_db,
|
|
357
|
+
artifacts_root=artifacts_root,
|
|
358
|
+
parent_env=parent_env,
|
|
359
|
+
ci=ci,
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
if outcome.skipped:
|
|
363
|
+
# Contract: treat resting as success (exit_code 0). Emit a minimal `plan_finished`
|
|
364
|
+
# so UIs relying on a terminal event can close the stream.
|
|
365
|
+
_append_ndjson_line(
|
|
366
|
+
events_path,
|
|
367
|
+
{
|
|
368
|
+
"event": "plan_finished",
|
|
369
|
+
"plan": plan.name,
|
|
370
|
+
"aggregate_returncode": 0,
|
|
371
|
+
"exit_code": 0,
|
|
372
|
+
"duration_s": 0.0,
|
|
373
|
+
"stages": [],
|
|
374
|
+
"error": None,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
status, error = "completed", None
|
|
379
|
+
except Exception as exc: # pragma: no cover (defensive: surfaces in API)
|
|
380
|
+
# Broad on purpose: a background run's crash must reach the client as an error event.
|
|
381
|
+
# Keep error exposure minimal (redaction happens upstream in API error formatting where needed).
|
|
382
|
+
logger.exception("execution %s of cycle %s crashed", state.execution_id, state.cycle)
|
|
383
|
+
err = str(exc)
|
|
384
|
+
try:
|
|
385
|
+
if state.events_path is not None:
|
|
386
|
+
_append_ndjson_line(
|
|
387
|
+
state.events_path,
|
|
388
|
+
{"event": "error", "code": "internal_error", "message": err},
|
|
389
|
+
)
|
|
390
|
+
except OSError:
|
|
391
|
+
logger.warning(
|
|
392
|
+
"could not write error event for %s", state.execution_id, exc_info=True
|
|
393
|
+
)
|
|
394
|
+
error = err
|
|
395
|
+
finally:
|
|
396
|
+
# Record where this run's events end before freeing the cycle: the next run
|
|
397
|
+
# appends to the same file, and streams of this run must stop here.
|
|
398
|
+
if state.events_path is not None:
|
|
399
|
+
try:
|
|
400
|
+
end = state.events_path.stat().st_size
|
|
401
|
+
except OSError:
|
|
402
|
+
end = state.events_start_offset_bytes
|
|
403
|
+
with state.lock:
|
|
404
|
+
state.events_end_offset_bytes = end
|
|
405
|
+
# Free the cycle and update the registry before reporting `done`, so a client
|
|
406
|
+
# that sees the run finish can start the cycle again right away.
|
|
407
|
+
with self._states_lock:
|
|
408
|
+
if self._active_by_cycle.get(state.cycle) == state.execution_id:
|
|
409
|
+
self._active_by_cycle.pop(state.cycle, None)
|
|
410
|
+
self._finished.append(state.execution_id)
|
|
411
|
+
while len(self._finished) > self.max_finished:
|
|
412
|
+
self._states.pop(self._finished.popleft(), None)
|
|
413
|
+
state.mark_done(status=status, error=error)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _sse_message(payload: dict[str, object]) -> str:
|
|
417
|
+
event_name = str(payload.get("event") or "unknown")
|
|
418
|
+
data = json.dumps(payload, separators=(",", ":"), ensure_ascii=True)
|
|
419
|
+
return f"event: {event_name}\ndata: {data}\n\n"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _read_ndjson(path: Path, offset: int, end: int | None) -> tuple[list[dict[str, object]], int]:
|
|
423
|
+
"""Parse the NDJSON objects after byte ``offset``; return them and the new offset.
|
|
424
|
+
|
|
425
|
+
While the run is live (``end is None``) a trailing line without its newline is
|
|
426
|
+
still being written, so it is left for the next read. Once the run is done, read
|
|
427
|
+
exactly up to ``end`` (the file is shared by later runs of the same cycle).
|
|
428
|
+
"""
|
|
429
|
+
try:
|
|
430
|
+
with path.open("rb") as fh:
|
|
431
|
+
fh.seek(offset)
|
|
432
|
+
chunk = fh.read() if end is None else fh.read(max(0, end - offset))
|
|
433
|
+
except OSError: # not created yet
|
|
434
|
+
return [], offset if end is None else end
|
|
435
|
+
consumed = chunk.rfind(b"\n") + 1 if end is None else len(chunk)
|
|
436
|
+
payloads: list[dict[str, object]] = []
|
|
437
|
+
for raw in chunk[:consumed].splitlines():
|
|
438
|
+
try:
|
|
439
|
+
payload = json.loads(raw)
|
|
440
|
+
except json.JSONDecodeError:
|
|
441
|
+
continue
|
|
442
|
+
if isinstance(payload, dict):
|
|
443
|
+
payloads.append(payload)
|
|
444
|
+
return payloads, offset + consumed if end is None else end
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
async def iter_sse_from_ndjson_file(
|
|
448
|
+
*,
|
|
449
|
+
events_path: Path,
|
|
450
|
+
start_offset_bytes: int,
|
|
451
|
+
end_offset: Callable[[], int | None],
|
|
452
|
+
min_poll_s: float = 0.1,
|
|
453
|
+
max_poll_s: float = 2.0,
|
|
454
|
+
keepalive_s: float = 15.0,
|
|
455
|
+
) -> AsyncIterator[str]:
|
|
456
|
+
"""
|
|
457
|
+
Tail an NDJSON file and emit each JSON object as an SSE message.
|
|
458
|
+
|
|
459
|
+
The `data:` payload is the full NDJSON object (including top-level `event`),
|
|
460
|
+
aligned to `docs/CLI Commands/Troubleshooting and Error Codes.md`.
|
|
461
|
+
|
|
462
|
+
`end_offset()` is None while the execution runs and, once it is done, the byte
|
|
463
|
+
offset where its events end; the stream drains up to there and closes.
|
|
464
|
+
|
|
465
|
+
Per viewer this is one coroutine and a file offset. It sleeps on the event loop
|
|
466
|
+
(a sync generator would hold a threadpool thread per viewer) and the poll interval
|
|
467
|
+
backs off from `min_poll_s` to `max_poll_s` while nothing new is written. When
|
|
468
|
+
the client disconnects, Starlette cancels the response task, which ends the
|
|
469
|
+
stream at its next `await`.
|
|
470
|
+
"""
|
|
471
|
+
offset = max(0, int(start_offset_bytes))
|
|
472
|
+
interval = min_poll_s
|
|
473
|
+
last_sent = time.monotonic()
|
|
474
|
+
while True:
|
|
475
|
+
# Sample the end before reading: it is recorded after the worker's last event,
|
|
476
|
+
# so this read is guaranteed to include that event.
|
|
477
|
+
end = end_offset()
|
|
478
|
+
# Off the event loop: a late viewer may have a large backlog to read.
|
|
479
|
+
payloads, offset = await asyncio.to_thread(_read_ndjson, events_path, offset, end)
|
|
480
|
+
for payload in payloads:
|
|
481
|
+
yield _sse_message(payload)
|
|
482
|
+
if end is not None:
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
now = time.monotonic()
|
|
486
|
+
if payloads:
|
|
487
|
+
interval = min_poll_s
|
|
488
|
+
last_sent = now
|
|
489
|
+
else:
|
|
490
|
+
interval = min(interval * 2, max_poll_s)
|
|
491
|
+
if now - last_sent >= keepalive_s:
|
|
492
|
+
# Comment line: keeps proxies from timing out an idle stream.
|
|
493
|
+
yield ": keep-alive\n\n"
|
|
494
|
+
last_sent = now
|
|
495
|
+
await asyncio.sleep(interval)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def iter_execution_sse(
|
|
499
|
+
manager: CycleExecutionManager,
|
|
500
|
+
execution_id: str,
|
|
501
|
+
*,
|
|
502
|
+
min_poll_s: float = 0.1,
|
|
503
|
+
max_poll_s: float = 2.0,
|
|
504
|
+
) -> AsyncIterator[str]:
|
|
505
|
+
"""SSE stream for one execution: wait until its events file is known, then tail it."""
|
|
506
|
+
# The worker thread learns the events path only after loading the config.
|
|
507
|
+
interval = min_poll_s
|
|
508
|
+
while True:
|
|
509
|
+
state = manager.get(execution_id)
|
|
510
|
+
if state is None: # evicted: it finished long ago and its result is in run history
|
|
511
|
+
return
|
|
512
|
+
with state.lock:
|
|
513
|
+
events_path, start_offset = state.events_path, state.events_start_offset_bytes
|
|
514
|
+
done, error = state.done, state.error
|
|
515
|
+
if events_path is not None:
|
|
516
|
+
break
|
|
517
|
+
if done:
|
|
518
|
+
# Failed before it had an events file (e.g. a config error): say why.
|
|
519
|
+
message = error or "execution finished without events"
|
|
520
|
+
yield _sse_message({"event": "error", "code": "internal_error", "message": message})
|
|
521
|
+
return
|
|
522
|
+
await asyncio.sleep(interval)
|
|
523
|
+
interval = min(interval * 2, max_poll_s)
|
|
524
|
+
|
|
525
|
+
def end_offset() -> int | None:
|
|
526
|
+
current = manager.get(execution_id)
|
|
527
|
+
if current is None: # evicted mid-stream: nothing more of this run to send
|
|
528
|
+
return 0
|
|
529
|
+
with current.lock:
|
|
530
|
+
return (current.events_end_offset_bytes or 0) if current.done else None
|
|
531
|
+
|
|
532
|
+
async for message in iter_sse_from_ndjson_file(
|
|
533
|
+
events_path=events_path,
|
|
534
|
+
start_offset_bytes=start_offset,
|
|
535
|
+
end_offset=end_offset,
|
|
536
|
+
min_poll_s=min_poll_s,
|
|
537
|
+
max_poll_s=max_poll_s,
|
|
538
|
+
):
|
|
539
|
+
yield message
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from testo_api.cycle_execution_manager import CycleExecutionManager
|
|
4
|
+
from testo_core.services.ai.integration_settings import InMemoryAiSettingsStore
|
|
5
|
+
from testo_core.services.failure_analysis_service import FailureAnalysisService
|
|
6
|
+
|
|
7
|
+
_CYCLE_MANAGER = CycleExecutionManager()
|
|
8
|
+
_AI_SETTINGS_STORE = InMemoryAiSettingsStore()
|
|
9
|
+
_FAILURE_ANALYSIS_SERVICE = FailureAnalysisService(settings_store=_AI_SETTINGS_STORE)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_cycle_execution_manager() -> CycleExecutionManager:
|
|
13
|
+
return _CYCLE_MANAGER
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_ai_settings_store() -> InMemoryAiSettingsStore:
|
|
17
|
+
return _AI_SETTINGS_STORE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_failure_analysis_service() -> FailureAnalysisService:
|
|
21
|
+
return _FAILURE_ANALYSIS_SERVICE
|