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.
Files changed (103) hide show
  1. {fixtureqa-0.5.0/fixtureqa.egg-info → fixtureqa-0.7.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/README.md +1 -1
  3. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/schemas.py +3 -0
  4. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_payload.py +10 -41
  5. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/scenario_runner.py +131 -63
  6. fixtureqa-0.7.0/fixture/core/value_gen.py +100 -0
  7. fixtureqa-0.5.0/fixture/static/assets/index-C6r9qHOJ.css → fixtureqa-0.7.0/fixture/static/assets/index-B-hpSZWZ.css +1 -1
  8. fixtureqa-0.5.0/fixture/static/assets/index-vqpCGgnx.js → fixtureqa-0.7.0/fixture/static/assets/index-BJKwfdd8.js +22 -22
  9. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/index.html +2 -2
  10. {fixtureqa-0.5.0 → fixtureqa-0.7.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  11. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/SOURCES.txt +4 -2
  12. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/pyproject.toml +1 -1
  13. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_scenarios.py +131 -0
  14. fixtureqa-0.7.0/tests/test_value_gen.py +45 -0
  15. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/LICENSE +0 -0
  16. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/__init__.py +0 -0
  17. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/__main__.py +0 -0
  18. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/__init__.py +0 -0
  19. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/app.py +0 -0
  20. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/connection_manager.py +0 -0
  21. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/deps.py +0 -0
  22. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/__init__.py +0 -0
  23. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/admin.py +0 -0
  24. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/auth.py +0 -0
  25. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/branding.py +0 -0
  26. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/custom_tags.py +0 -0
  27. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/fix_spec.py +0 -0
  28. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/messages.py +0 -0
  29. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/perf.py +0 -0
  30. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/scenarios.py +0 -0
  31. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/sessions.py +0 -0
  32. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/setup.py +0 -0
  33. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/spec_overlay.py +0 -0
  34. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/templates.py +0 -0
  35. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/api/routers/ws.py +0 -0
  36. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/config/__init__.py +0 -0
  37. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/__init__.py +0 -0
  38. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/atomic_io.py +0 -0
  39. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/auth.py +0 -0
  40. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/config_store.py +0 -0
  41. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/custom_tag_store.py +0 -0
  42. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/db_migrations.py +0 -0
  43. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/events.py +0 -0
  44. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_application.py +0 -0
  45. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_builder.py +0 -0
  46. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_parser.py +0 -0
  47. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_spec_parser.py +0 -0
  48. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_tags.py +0 -0
  49. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/fix_time.py +0 -0
  50. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/housekeeping.py +0 -0
  51. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/inbound.py +0 -0
  52. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/json_store.py +0 -0
  53. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/latency_csv_writer.py +0 -0
  54. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/message_log.py +0 -0
  55. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/message_store.py +0 -0
  56. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/models.py +0 -0
  57. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_charts.py +0 -0
  58. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_engine.py +0 -0
  59. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_models.py +0 -0
  60. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_stats.py +0 -0
  61. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_store.py +0 -0
  62. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/perf_writer.py +0 -0
  63. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/scenario_store.py +0 -0
  64. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/session.py +0 -0
  65. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/session_manager.py +0 -0
  66. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/spec_overlay_store.py +0 -0
  67. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/template_store.py +0 -0
  68. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/user_store.py +0 -0
  69. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/core/venue_responses.py +0 -0
  70. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/fix_specs/FIX42.xml +0 -0
  71. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/fix_specs/FIX44.xml +0 -0
  72. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/server.py +0 -0
  73. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  74. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  75. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  76. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/static/favicon.svg +0 -0
  77. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixture/ui/__init__.py +0 -0
  78. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  79. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  80. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/requires.txt +0 -0
  81. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/setup.cfg +0 -0
  83. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_db_migrations.py +0 -0
  88. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_fix_builder.py +0 -0
  89. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_health.py +0 -0
  90. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_api.py +0 -0
  94. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_engine.py +0 -0
  95. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_models.py +0 -0
  96. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_payload.py +0 -0
  97. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_perf_rehydrate.py +0 -0
  98. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_session_auto_response.py +0 -0
  99. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_session_lifecycle.py +0 -0
  100. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_session_manager_concurrency.py +0 -0
  101. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_sessions.py +0 -0
  102. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_templates.py +0 -0
  103. {fixtureqa-0.5.0 → fixtureqa-0.7.0}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -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 (mirror frontend SendOptions.evalGenerator) ----------
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 = re.compile(r"^(\w+)\(([^)]*)\)$")
102
- _ALNUM = string.ascii_letters + string.digits
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
- """Evaluate one generator call; None = unknown name / invalid args."""
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
- """Evaluate a whole-value generator expression; pass literals through."""
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
- """True when a template value renders differently per message
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
- step_count = len(steps)
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
- for idx, step in enumerate(steps):
343
- if abort_flag.is_set():
344
- overall_status = "aborted"
345
- break
346
-
347
- label = step.get("label", f"Step {idx + 1}")
348
- step_type = step.get("type", "")
349
-
350
- self._broadcast(uid, {
351
- "type": "scenario_step",
352
- "run_id": run_id,
353
- "group_run_id": group_run_id,
354
- "step_index": idx,
355
- "label": label,
356
- "status": "running",
357
- "error": None,
358
- "duration_ms": 0,
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
- step_start = _now_ms()
362
- error: Optional[str] = None
363
- status = "pass"
423
+ status = "fail" if error is not None else "pass"
424
+ duration_ms = int(_now_ms() - step_start)
364
425
 
365
- try:
366
- if step_type == "send":
367
- error = self._run_send(step, scenario.get("session_id", ""), uid)
368
- elif step_type == "expect":
369
- error = self._run_expect(step, inbox, abort_flag)
370
- elif step_type == "delay":
371
- self._run_delay(step, abort_flag)
372
- if abort_flag.is_set():
373
- overall_status = "aborted"
374
- status = "fail"
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
- if status == "fail":
396
- if overall_status != "aborted":
397
- overall_status = "fail"
398
- break
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 _run_send(self, step: dict, session_id: str, uid: str) -> Optional[str]:
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), str(value))
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 or None."""
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)