fixtureqa 0.5.0__tar.gz → 0.7.0__tar.gz
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.
- {fixtureqa-0.5.0/fixtureqa.egg-info → fixtureqa-0.7.0}/PKG-INFO +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/README.md +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/schemas.py +3 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_payload.py +10 -41
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/scenario_runner.py +131 -63
- fixtureqa-0.7.0/fixture/core/value_gen.py +100 -0
- fixtureqa-0.5.0/fixture/static/assets/index-C6r9qHOJ.css → fixtureqa-0.7.0/fixture/static/assets/index-B-hpSZWZ.css +1 -1
- fixtureqa-0.5.0/fixture/static/assets/index-vqpCGgnx.js → fixtureqa-0.7.0/fixture/static/assets/index-BJKwfdd8.js +22 -22
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/index.html +2 -2
- {fixtureqa-0.5.0 → fixtureqa-0.7.0/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/SOURCES.txt +4 -2
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/pyproject.toml +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_scenarios.py +131 -0
- fixtureqa-0.7.0/tests/test_value_gen.py +45 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/LICENSE +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/app.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/deps.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/session.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/server.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/setup.cfg +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_config_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_health.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_message_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_templates.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_ws.py +0 -0
|
@@ -15,7 +15,7 @@ A Python FIX protocol testing tool. Supports FIX 4.2 and 4.4, dynamic session cr
|
|
|
15
15
|
- **Message log** — real-time IN/OUT stream with field breakdown, filtering, cursor pagination
|
|
16
16
|
- **Compose & send** — paste raw FIX or use message templates; auto-inject tag 60 and primary ID
|
|
17
17
|
- **Templates** — create/edit reusable message templates with FIX spec field definitions
|
|
18
|
-
- **Scenarios** — automated send/expect/delay test scripts with live pass/fail results
|
|
18
|
+
- **Scenarios** — automated send/expect/delay test scripts with live pass/fail results; repeatable steps/scenarios, value generators (`uuid()`, `ORD-{seq(8)}`, …) in send values, `{recv:<tag>}` to echo an order's tags into the ack/fill that answers it, and cross-session **suites** that coordinate through real FIX traffic (e.g. a client scenario and a venue scenario run together)
|
|
19
19
|
- **Venue simulation** — auto-ack and auto-fill responses for inbound NewOrderSingles
|
|
20
20
|
- **Performance testing** — rate-controlled order injection with latency correlation (p50/p95/p99), live dashboard, run history and CSV export; order/exec shapes templatable per counterparty; amend (35=G) and cancel (35=F) flavours with their own reply latencies (see [PERF.md](PERF.md))
|
|
21
21
|
- **Log analysis** — Overview stats, Throughput chart, Reject detail, Latency measurement (p50/p95/p99)
|
|
@@ -266,12 +266,14 @@ class ScenarioStepDef(BaseModel):
|
|
|
266
266
|
timeout_ms: int = 5000
|
|
267
267
|
assertions: list[AssertionDef] = []
|
|
268
268
|
delay_ms: int = 0
|
|
269
|
+
repeat: int = 1 # run this step N times (one reported row, "(×N)")
|
|
269
270
|
|
|
270
271
|
|
|
271
272
|
class ScenarioRequest(BaseModel):
|
|
272
273
|
name: str
|
|
273
274
|
session_id: str
|
|
274
275
|
steps: list[ScenarioStepDef]
|
|
276
|
+
repeat: int = 1 # replay the whole step list N times
|
|
275
277
|
|
|
276
278
|
|
|
277
279
|
class ScenarioResponse(BaseModel):
|
|
@@ -279,6 +281,7 @@ class ScenarioResponse(BaseModel):
|
|
|
279
281
|
name: str
|
|
280
282
|
session_id: str
|
|
281
283
|
steps: list[ScenarioStepDef]
|
|
284
|
+
repeat: int = 1
|
|
282
285
|
|
|
283
286
|
|
|
284
287
|
class ScenarioGroupRequest(BaseModel):
|
|
@@ -40,12 +40,12 @@ from __future__ import annotations
|
|
|
40
40
|
|
|
41
41
|
import random
|
|
42
42
|
import re
|
|
43
|
-
import string
|
|
44
43
|
import uuid
|
|
45
44
|
from typing import Optional
|
|
46
45
|
|
|
47
46
|
from fixcore.message.message import Message
|
|
48
47
|
|
|
48
|
+
from . import value_gen
|
|
49
49
|
from .fix_builder import CONTROL_TAGS
|
|
50
50
|
from .fix_time import utc_timestamp
|
|
51
51
|
from .perf_models import RunConfig, ScenarioConfig
|
|
@@ -96,57 +96,26 @@ def _tokens(value: str) -> list[str]:
|
|
|
96
96
|
return _TOKEN_RE.findall(value) if isinstance(value, str) else []
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
# -- value generators
|
|
99
|
+
# -- value generators -------------------------------------------------------
|
|
100
|
+
# The generator set (uuid()/seq()/random_*()/timestamp()/date()) lives in the
|
|
101
|
+
# shared core.value_gen module so perf and the scenario runner evaluate field
|
|
102
|
+
# values identically. Aliased here to keep the call sites below unchanged.
|
|
100
103
|
|
|
101
|
-
_GEN_RE =
|
|
102
|
-
|
|
103
|
-
_GENERATOR_NAMES = {"uuid", "random_str", "random_int", "seq", "timestamp", "date"}
|
|
104
|
+
_GEN_RE = value_gen.GEN_RE
|
|
105
|
+
_GENERATOR_NAMES = value_gen.GENERATOR_NAMES
|
|
104
106
|
_EXPIRY_UNIT_S = {"minutes": 60, "hours": 3600, "days": 86400}
|
|
105
107
|
|
|
106
108
|
|
|
107
109
|
def _gen_call(name: str, raw_args: str, tag: int, counters: dict[int, int]) -> Optional[str]:
|
|
108
|
-
|
|
109
|
-
args = [a.strip() for a in raw_args.split(",")] if raw_args.strip() else []
|
|
110
|
-
if name == "uuid":
|
|
111
|
-
return _uuid()
|
|
112
|
-
if name == "random_str":
|
|
113
|
-
n = _to_int(args[0], 8) if args else 8
|
|
114
|
-
return "".join(random.choices(_ALNUM, k=min(n, 256) if n > 0 else 8))
|
|
115
|
-
if name == "random_int":
|
|
116
|
-
try:
|
|
117
|
-
a, b = int(float(args[0])), int(float(args[1]))
|
|
118
|
-
except (IndexError, TypeError, ValueError):
|
|
119
|
-
return None
|
|
120
|
-
return str(random.randint(min(a, b), max(a, b)))
|
|
121
|
-
if name == "seq":
|
|
122
|
-
# seq() / seq(width) / seq(width, start) — incrementing counter,
|
|
123
|
-
# zero-padded to width; one counter per tag per run.
|
|
124
|
-
width = _to_int(args[0], 0) if args else 0
|
|
125
|
-
start = _to_int(args[1], 1) if len(args) > 1 else 1
|
|
126
|
-
n = counters.get(tag, start)
|
|
127
|
-
counters[tag] = n + 1
|
|
128
|
-
return str(n).zfill(width) if width > 0 else str(n)
|
|
129
|
-
if name == "timestamp":
|
|
130
|
-
return utc_timestamp()
|
|
131
|
-
if name == "date":
|
|
132
|
-
return utc_timestamp()[:8]
|
|
133
|
-
return None
|
|
110
|
+
return value_gen.gen_call(name, raw_args, tag, counters)
|
|
134
111
|
|
|
135
112
|
|
|
136
113
|
def _eval_generator(value: str, tag: int, counters: dict[int, int]) -> str:
|
|
137
|
-
|
|
138
|
-
m = _GEN_RE.match(value.strip())
|
|
139
|
-
if not m:
|
|
140
|
-
return value
|
|
141
|
-
out = _gen_call(m.group(1), m.group(2), tag, counters)
|
|
142
|
-
return value if out is None else out
|
|
114
|
+
return value_gen.eval_generator(value, tag, counters)
|
|
143
115
|
|
|
144
116
|
|
|
145
117
|
def _is_dynamic(value: str) -> bool:
|
|
146
|
-
|
|
147
|
-
(contains a {token} or is a whole-value generator call)."""
|
|
148
|
-
return isinstance(value, str) and (
|
|
149
|
-
"{" in value or _GEN_RE.match(value.strip()) is not None)
|
|
118
|
+
return value_gen.is_dynamic(value)
|
|
150
119
|
|
|
151
120
|
|
|
152
121
|
class PayloadFactory:
|
|
@@ -9,6 +9,7 @@ for timeout and abort signalling.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
+
import re
|
|
12
13
|
import threading
|
|
13
14
|
import time
|
|
14
15
|
import uuid
|
|
@@ -21,14 +22,29 @@ from .fix_builder import _ordered_pairs
|
|
|
21
22
|
from .fix_time import utc_timestamp
|
|
22
23
|
from .session_manager import SessionManager
|
|
23
24
|
from .template_store import TemplateStore
|
|
25
|
+
from . import value_gen
|
|
24
26
|
|
|
25
27
|
SKIP_TAGS = {8, 9, 10, 34, 35, 49, 52, 56}
|
|
26
28
|
|
|
29
|
+
# {recv:<tag>} in a send value echoes that tag from the message the run's most
|
|
30
|
+
# recent expect step matched (e.g. a venue ack carries the order's ClOrdID via
|
|
31
|
+
# 11={recv:11}). No parens, so it never collides with value_gen's {gen(...)}.
|
|
32
|
+
_RECV_RE = re.compile(r"\{recv:(\d+)\}")
|
|
33
|
+
|
|
27
34
|
|
|
28
35
|
def _now_ms() -> float:
|
|
29
36
|
return time.monotonic() * 1000
|
|
30
37
|
|
|
31
38
|
|
|
39
|
+
def _coerce_repeat(value) -> int:
|
|
40
|
+
"""Repeat counts default to and are floored at 1 — 0/negative/None/garbage
|
|
41
|
+
all become 1 so a malformed config can't silently skip a step."""
|
|
42
|
+
try:
|
|
43
|
+
return max(1, int(value))
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
|
|
32
48
|
def _utc_ts() -> str:
|
|
33
49
|
return utc_timestamp()
|
|
34
50
|
|
|
@@ -264,7 +280,9 @@ class ScenarioRunner:
|
|
|
264
280
|
scenario_id = scenario.get("id", "")
|
|
265
281
|
scenario_name = scenario.get("name", "")
|
|
266
282
|
steps = scenario.get("steps", [])
|
|
267
|
-
|
|
283
|
+
# Scenario-level repeat replays the whole list; each replay's steps get
|
|
284
|
+
# their own rows, so the run view expects len(steps) * repeat rows.
|
|
285
|
+
step_count = len(steps) * _coerce_repeat(scenario.get("repeat", 1))
|
|
268
286
|
|
|
269
287
|
self._broadcast(uid, {
|
|
270
288
|
"type": "scenario_start",
|
|
@@ -339,76 +357,121 @@ class ScenarioRunner:
|
|
|
339
357
|
def _run_steps(self, scenario: dict, uid: str, run_id: str,
|
|
340
358
|
abort_flag: threading.Event, steps: list, inbox: _RunInbox,
|
|
341
359
|
overall_status: str, group_run_id: Optional[str] = None) -> str:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
+
# The whole step list can run several times (scenario-level repeat); each
|
|
361
|
+
# iteration's steps get fresh, continuous step indices (so step_count =
|
|
362
|
+
# len(steps) * scenario_repeat, computed in _execute). A step can itself
|
|
363
|
+
# repeat (step-level repeat) — that stays ONE reported row labelled "(×N)",
|
|
364
|
+
# fail-fast on the first bad iteration, with its duration summed.
|
|
365
|
+
scenario_repeat = _coerce_repeat(scenario.get("repeat", 1))
|
|
366
|
+
session_id = scenario.get("session_id", "")
|
|
367
|
+
# Run-scoped seq() state so 11=ORD-{seq(4)} keeps counting across a
|
|
368
|
+
# repeated step and across scenario-level repeats (uuid()/random are
|
|
369
|
+
# always fresh regardless).
|
|
370
|
+
gen_counters: dict = {}
|
|
371
|
+
# Fields of the message the most recent expect matched, so a following
|
|
372
|
+
# send can echo them via {recv:<tag>} (FIFO: expect k matched order k).
|
|
373
|
+
last_recv: dict = {}
|
|
374
|
+
idx = 0
|
|
375
|
+
for run_iter in range(scenario_repeat):
|
|
376
|
+
for step in steps:
|
|
377
|
+
if abort_flag.is_set():
|
|
378
|
+
return "aborted"
|
|
379
|
+
|
|
380
|
+
label = step.get("label", f"Step {idx + 1}")
|
|
381
|
+
step_type = step.get("type", "")
|
|
382
|
+
step_repeat = _coerce_repeat(step.get("repeat", 1))
|
|
383
|
+
if step_repeat > 1:
|
|
384
|
+
label += f" (×{step_repeat})"
|
|
385
|
+
if scenario_repeat > 1:
|
|
386
|
+
label += f" (run {run_iter + 1}/{scenario_repeat})"
|
|
387
|
+
|
|
388
|
+
self._broadcast(uid, {
|
|
389
|
+
"type": "scenario_step",
|
|
390
|
+
"run_id": run_id,
|
|
391
|
+
"group_run_id": group_run_id,
|
|
392
|
+
"step_index": idx,
|
|
393
|
+
"label": label,
|
|
394
|
+
"status": "running",
|
|
395
|
+
"error": None,
|
|
396
|
+
"duration_ms": 0,
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
step_start = _now_ms()
|
|
400
|
+
error: Optional[str] = None
|
|
401
|
+
for iteration in range(step_repeat):
|
|
402
|
+
try:
|
|
403
|
+
if step_type == "send":
|
|
404
|
+
error = self._run_send(step, session_id, uid, gen_counters, last_recv)
|
|
405
|
+
elif step_type == "expect":
|
|
406
|
+
error, matched = self._run_expect(step, inbox, abort_flag)
|
|
407
|
+
if matched is not None:
|
|
408
|
+
last_recv = matched
|
|
409
|
+
elif step_type == "delay":
|
|
410
|
+
self._run_delay(step, abort_flag)
|
|
411
|
+
if abort_flag.is_set():
|
|
412
|
+
overall_status = "aborted"
|
|
413
|
+
error = "Aborted"
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
error = str(exc)
|
|
416
|
+
if error is not None:
|
|
417
|
+
if step_repeat > 1:
|
|
418
|
+
error = f"iteration {iteration + 1}/{step_repeat}: {error}"
|
|
419
|
+
break
|
|
420
|
+
if abort_flag.is_set():
|
|
421
|
+
break
|
|
360
422
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
status = "pass"
|
|
423
|
+
status = "fail" if error is not None else "pass"
|
|
424
|
+
duration_ms = int(_now_ms() - step_start)
|
|
364
425
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
error = "Aborted"
|
|
376
|
-
except Exception as exc:
|
|
377
|
-
error = str(exc)
|
|
378
|
-
|
|
379
|
-
if error is not None:
|
|
380
|
-
status = "fail"
|
|
381
|
-
|
|
382
|
-
duration_ms = int(_now_ms() - step_start)
|
|
383
|
-
|
|
384
|
-
self._broadcast(uid, {
|
|
385
|
-
"type": "scenario_step",
|
|
386
|
-
"run_id": run_id,
|
|
387
|
-
"group_run_id": group_run_id,
|
|
388
|
-
"step_index": idx,
|
|
389
|
-
"label": label,
|
|
390
|
-
"status": status,
|
|
391
|
-
"error": error,
|
|
392
|
-
"duration_ms": duration_ms,
|
|
393
|
-
})
|
|
426
|
+
self._broadcast(uid, {
|
|
427
|
+
"type": "scenario_step",
|
|
428
|
+
"run_id": run_id,
|
|
429
|
+
"group_run_id": group_run_id,
|
|
430
|
+
"step_index": idx,
|
|
431
|
+
"label": label,
|
|
432
|
+
"status": status,
|
|
433
|
+
"error": error,
|
|
434
|
+
"duration_ms": duration_ms,
|
|
435
|
+
})
|
|
394
436
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
437
|
+
idx += 1
|
|
438
|
+
|
|
439
|
+
if status == "fail":
|
|
440
|
+
if overall_status != "aborted":
|
|
441
|
+
overall_status = "fail"
|
|
442
|
+
return overall_status
|
|
399
443
|
|
|
400
444
|
return overall_status
|
|
401
445
|
|
|
402
|
-
def
|
|
446
|
+
def _resolve_value(self, value: str, counters: dict, recv_fields: dict,
|
|
447
|
+
key=0) -> str:
|
|
448
|
+
"""Resolve {recv:<tag>} (echo a tag from the last matched message) then
|
|
449
|
+
the value_gen generators ({gen(...)} / whole-value). recv runs first so
|
|
450
|
+
it composes with generators (e.g. 17=EX-{recv:11}); an absent tag → ""."""
|
|
451
|
+
if isinstance(value, str) and recv_fields and "{recv:" in value:
|
|
452
|
+
value = _RECV_RE.sub(
|
|
453
|
+
lambda m: (recv_fields.get(m.group(1)) or [""])[0], value)
|
|
454
|
+
return value_gen.eval_field_value(value, counters, key)
|
|
455
|
+
|
|
456
|
+
def _run_send(self, step: dict, session_id: str, uid: str,
|
|
457
|
+
counters: dict, recv_fields: dict) -> Optional[str]:
|
|
403
458
|
"""Build and send a FIX message. Returns error string or None.
|
|
404
459
|
|
|
405
460
|
A 'raw' field is built group-aware via the session's DataDictionary, so
|
|
406
461
|
repeating groups survive. Otherwise template + step fields merge into a
|
|
407
462
|
flat dict (no repeating groups possible from a dict).
|
|
463
|
+
|
|
464
|
+
Field values may use the same generators as the Send Options UI —
|
|
465
|
+
uuid()/seq()/random_*()/timestamp()/date(), whole-value or embedded as
|
|
466
|
+
{gen(...)} — evaluated fresh per send. `counters` is the run-scoped seq()
|
|
467
|
+
state (passed in so seq() keeps incrementing across a repeated step / a
|
|
468
|
+
repeated scenario, e.g. 11=ORD-{seq(4)} → ORD-0001, ORD-0002, …).
|
|
469
|
+
`recv_fields` is the last expect's matched message, echoed via {recv:tag}.
|
|
408
470
|
"""
|
|
409
471
|
raw_field = step.get("raw", "").strip()
|
|
410
472
|
if raw_field:
|
|
411
473
|
from .fix_builder import build_message_from_raw
|
|
474
|
+
raw_field = self._resolve_value(raw_field, counters, recv_fields)
|
|
412
475
|
try:
|
|
413
476
|
msg = build_message_from_raw(
|
|
414
477
|
raw_field, self._sm.get_data_dictionary(session_id), transact_time=_utc_ts())
|
|
@@ -448,7 +511,8 @@ class ScenarioRunner:
|
|
|
448
511
|
for tag, value in fields.items():
|
|
449
512
|
if tag not in SKIP_TAGS:
|
|
450
513
|
try:
|
|
451
|
-
msg.set_field(int(tag),
|
|
514
|
+
msg.set_field(int(tag),
|
|
515
|
+
self._resolve_value(str(value), counters, recv_fields, tag))
|
|
452
516
|
except Exception:
|
|
453
517
|
pass
|
|
454
518
|
|
|
@@ -464,9 +528,13 @@ class ScenarioRunner:
|
|
|
464
528
|
|
|
465
529
|
def _run_expect(
|
|
466
530
|
self, step: dict, inbox: _RunInbox, abort_flag: threading.Event
|
|
467
|
-
) -> Optional[str]:
|
|
531
|
+
) -> tuple[Optional[str], Optional[dict]]:
|
|
468
532
|
"""Consume the next inbound message matching msg_type from the run inbox
|
|
469
|
-
and evaluate the step's assertions against it. Returns error
|
|
533
|
+
and evaluate the step's assertions against it. Returns (error, fields):
|
|
534
|
+
error is None on success; fields is the matched message's multi-value
|
|
535
|
+
field dict (None on timeout/abort) so a following send can echo it via
|
|
536
|
+
{recv:<tag>}. The matched fields are returned even when an assertion
|
|
537
|
+
fails (the consumed message is still the one we looked at)."""
|
|
470
538
|
expected_msg_type = step.get("msg_type", "")
|
|
471
539
|
timeout_ms = step.get("timeout_ms", 5000)
|
|
472
540
|
assertions: list[dict] = step.get("assertions", [])
|
|
@@ -474,10 +542,10 @@ class ScenarioRunner:
|
|
|
474
542
|
item = inbox.next_matching(expected_msg_type, timeout_ms / 1000.0, abort_flag)
|
|
475
543
|
|
|
476
544
|
if abort_flag.is_set():
|
|
477
|
-
return "Aborted"
|
|
545
|
+
return "Aborted", None
|
|
478
546
|
if item is None:
|
|
479
547
|
desc = expected_msg_type or "any"
|
|
480
|
-
return f"Timeout waiting for message type {desc!r} after {timeout_ms}ms"
|
|
548
|
+
return f"Timeout waiting for message type {desc!r} after {timeout_ms}ms", None
|
|
481
549
|
|
|
482
550
|
fields = item["fields"]
|
|
483
551
|
for assertion in assertions:
|
|
@@ -487,8 +555,8 @@ class ScenarioRunner:
|
|
|
487
555
|
value = assertion.get("value", "")
|
|
488
556
|
actual_list = fields.get(str(tag))
|
|
489
557
|
actual_repr = repr(actual_list[0]) if actual_list and len(actual_list) == 1 else repr(actual_list) if actual_list else "<absent>"
|
|
490
|
-
return f"Assertion failed: tag {tag} {op} {value!r} (actual: {actual_repr})"
|
|
491
|
-
return None
|
|
558
|
+
return f"Assertion failed: tag {tag} {op} {value!r} (actual: {actual_repr})", fields
|
|
559
|
+
return None, fields
|
|
492
560
|
|
|
493
561
|
def _run_delay(self, step: dict, abort_flag: threading.Event) -> None:
|
|
494
562
|
delay_ms = step.get("delay_ms", 0)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Server-side value generators for outbound FIX field values.
|
|
2
|
+
|
|
3
|
+
uuid() · random_str(n) · random_int(a,b) · seq(width[,start]) · timestamp() ·
|
|
4
|
+
date() — mirrors the frontend SendOptions.evalFieldValue so a value typed in
|
|
5
|
+
either place behaves the same. A value that is exactly one recognised call is
|
|
6
|
+
replaced wholesale; generators also embed in text as {gen(...)} —
|
|
7
|
+
e.g. "ORD-{seq(8)}" → "ORD-00000001". Anything else passes through unchanged.
|
|
8
|
+
|
|
9
|
+
This is the single server-side implementation, shared by the perf payload
|
|
10
|
+
factory (perf_payload.py) and the scenario runner (scenario_runner.py).
|
|
11
|
+
|
|
12
|
+
seq() state lives in a caller-supplied `counters` dict keyed by an arbitrary
|
|
13
|
+
`key` (the perf factory and scenario fields use the FIX tag, so each tag has
|
|
14
|
+
its own running counter); the caller owns the dict's lifetime (one per run /
|
|
15
|
+
per send).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import random
|
|
21
|
+
import re
|
|
22
|
+
import string
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from .fix_time import utc_timestamp
|
|
26
|
+
|
|
27
|
+
GENERATOR_NAMES = {"uuid", "random_str", "random_int", "seq", "timestamp", "date"}
|
|
28
|
+
GEN_RE = re.compile(r"^(\w+)\(([^)]*)\)$")
|
|
29
|
+
_EMBED_RE = re.compile(r"\{(\w+)\(([^)]*)\)\}")
|
|
30
|
+
_ALNUM = string.ascii_letters + string.digits
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_int(v, default: int = 0) -> int:
|
|
34
|
+
try:
|
|
35
|
+
return int(float(v))
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def gen_call(name: str, raw_args: str, key, counters: dict) -> Optional[str]:
|
|
41
|
+
"""Evaluate one generator call; None = unknown name / invalid args."""
|
|
42
|
+
import uuid
|
|
43
|
+
args = [a.strip() for a in raw_args.split(",")] if raw_args.strip() else []
|
|
44
|
+
if name == "uuid":
|
|
45
|
+
return str(uuid.uuid4())
|
|
46
|
+
if name == "random_str":
|
|
47
|
+
n = _to_int(args[0], 8) if args else 8
|
|
48
|
+
return "".join(random.choices(_ALNUM, k=min(n, 256) if n > 0 else 8))
|
|
49
|
+
if name == "random_int":
|
|
50
|
+
try:
|
|
51
|
+
a, b = int(float(args[0])), int(float(args[1]))
|
|
52
|
+
except (IndexError, TypeError, ValueError):
|
|
53
|
+
return None
|
|
54
|
+
return str(random.randint(min(a, b), max(a, b)))
|
|
55
|
+
if name == "seq":
|
|
56
|
+
# seq() / seq(width) / seq(width, start) — incrementing counter,
|
|
57
|
+
# zero-padded to width; one counter per key.
|
|
58
|
+
width = _to_int(args[0], 0) if args else 0
|
|
59
|
+
start = _to_int(args[1], 1) if len(args) > 1 else 1
|
|
60
|
+
n = counters.get(key, start)
|
|
61
|
+
counters[key] = n + 1
|
|
62
|
+
return str(n).zfill(width) if width > 0 else str(n)
|
|
63
|
+
if name == "timestamp":
|
|
64
|
+
return utc_timestamp()
|
|
65
|
+
if name == "date":
|
|
66
|
+
return utc_timestamp()[:8]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def eval_generator(value: str, key, counters: dict) -> str:
|
|
71
|
+
"""Evaluate a whole-value generator expression; pass literals through."""
|
|
72
|
+
m = GEN_RE.match(value.strip())
|
|
73
|
+
if not m:
|
|
74
|
+
return value
|
|
75
|
+
out = gen_call(m.group(1), m.group(2), key, counters)
|
|
76
|
+
return value if out is None else out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def eval_field_value(value: str, counters: dict, key=0) -> str:
|
|
80
|
+
"""Whole-value generator OR generators embedded in text as {gen(...)}.
|
|
81
|
+
e.g. "uuid()" → a UUID; "ORD-{seq(8)}" → "ORD-00000001"; literals unchanged."""
|
|
82
|
+
if not isinstance(value, str):
|
|
83
|
+
return value
|
|
84
|
+
m = GEN_RE.match(value.strip())
|
|
85
|
+
if m:
|
|
86
|
+
out = gen_call(m.group(1), m.group(2), key, counters)
|
|
87
|
+
return value if out is None else out
|
|
88
|
+
|
|
89
|
+
def _sub(mm: re.Match) -> str:
|
|
90
|
+
out = gen_call(mm.group(1), mm.group(2), key, counters)
|
|
91
|
+
return mm.group(0) if out is None else out
|
|
92
|
+
|
|
93
|
+
return _EMBED_RE.sub(_sub, value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_dynamic(value: str) -> bool:
|
|
97
|
+
"""True when a value renders differently per message (a {token} or a
|
|
98
|
+
whole-value generator call)."""
|
|
99
|
+
return isinstance(value, str) and (
|
|
100
|
+
"{" in value or GEN_RE.match(value.strip()) is not None)
|