yaml-flow 6.0.0 → 7.1.0
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.
- package/board-live-cards-cli.js +4 -4
- package/browser/asset-integrity.json +3 -3
- package/browser/board-livecards-client.js +2 -0
- package/browser/board-livecards-client.js.map +1 -0
- package/browser/board-livecards-localstorage.js +10 -0
- package/browser/board-livecards-localstorage.js.map +1 -0
- package/browser/board-livegraph-engine.js +2 -2
- package/browser/board-livegraph-engine.js.map +1 -1
- package/browser/card-compute.js +28 -28
- package/browser/compute-jsonata.js +5 -0
- package/browser/compute-jsonata.js.map +1 -0
- package/browser/live-cards.js +264 -151
- package/card-store.js +4 -4
- package/dist/{board-live-cards-public-CltXYgaY.d.cts → board-live-cards-public-5n1-syA3.d.cts} +8 -5
- package/dist/{board-live-cards-public-f-E-FAyp.d.ts → board-live-cards-public-CK_J8uv0.d.ts} +8 -5
- package/dist/board-livegraph-runtime/index.cjs +2 -2
- package/dist/board-livegraph-runtime/index.cjs.map +1 -1
- package/dist/board-livegraph-runtime/index.d.cts +11 -9
- package/dist/board-livegraph-runtime/index.d.ts +11 -9
- package/dist/board-livegraph-runtime/index.js +2 -2
- package/dist/board-livegraph-runtime/index.js.map +1 -1
- package/dist/board-livegraph-runtime/jsonata-sync.cjs +37 -1
- package/dist/card-compute/index.cjs +4 -4
- package/dist/card-compute/index.cjs.map +1 -1
- package/dist/card-compute/index.d.cts +5 -1
- package/dist/card-compute/index.d.ts +5 -1
- package/dist/card-compute/index.js +4 -4
- package/dist/card-compute/index.js.map +1 -1
- package/dist/card-compute/jsonata-sync.cjs +37 -1
- package/dist/cli/browser-api/board-live-cards-browser-adapter.cjs +2 -1
- package/dist/cli/browser-api/board-live-cards-browser-adapter.cjs.map +1 -1
- package/dist/cli/browser-api/board-live-cards-browser-adapter.d.cts +27 -14
- package/dist/cli/browser-api/board-live-cards-browser-adapter.d.ts +27 -14
- package/dist/cli/browser-api/board-live-cards-browser-adapter.js +2 -1
- package/dist/cli/browser-api/board-live-cards-browser-adapter.js.map +1 -1
- package/dist/cli/browser-api/card-store-browser-api.cjs +1 -1
- package/dist/cli/browser-api/card-store-browser-api.cjs.map +1 -1
- package/dist/cli/browser-api/card-store-browser-api.js +1 -1
- package/dist/cli/browser-api/card-store-browser-api.js.map +1 -1
- package/dist/cli/browser-api/jsonata-sync.cjs +37 -1
- package/dist/cli/node/artifacts-store-cli.cjs +8 -8
- package/dist/cli/node/artifacts-store-cli.cjs.map +1 -1
- package/dist/cli/node/artifacts-store-cli.js +8 -8
- package/dist/cli/node/artifacts-store-cli.js.map +1 -1
- package/dist/cli/node/board-live-cards-cli.cjs +7 -7
- package/dist/cli/node/board-live-cards-cli.cjs.map +1 -1
- package/dist/cli/node/board-live-cards-cli.js +7 -7
- package/dist/cli/node/board-live-cards-cli.js.map +1 -1
- package/dist/cli/node/card-store-cli.cjs +5 -5
- package/dist/cli/node/card-store-cli.cjs.map +1 -1
- package/dist/cli/node/card-store-cli.js +5 -5
- package/dist/cli/node/card-store-cli.js.map +1 -1
- package/dist/cli/node/execution-adapter.cjs +3 -0
- package/dist/cli/node/execution-adapter.cjs.map +1 -0
- package/dist/cli/node/execution-adapter.d.cts +174 -0
- package/dist/cli/node/execution-adapter.d.ts +174 -0
- package/dist/cli/node/execution-adapter.js +3 -0
- package/dist/cli/node/execution-adapter.js.map +1 -0
- package/dist/cli/node/fs-board-adapter.cjs +7 -7
- package/dist/cli/node/fs-board-adapter.cjs.map +1 -1
- package/dist/cli/node/fs-board-adapter.d.cts +2 -2
- package/dist/cli/node/fs-board-adapter.d.ts +2 -2
- package/dist/cli/node/fs-board-adapter.js +7 -7
- package/dist/cli/node/fs-board-adapter.js.map +1 -1
- package/dist/cli/node/jsonata-sync.cjs +37 -1
- package/dist/cli/node/source-cli-task-executor.cjs +4 -4
- package/dist/cli/node/source-cli-task-executor.cjs.map +1 -1
- package/dist/cli/node/source-cli-task-executor.js +4 -4
- package/dist/cli/node/source-cli-task-executor.js.map +1 -1
- package/dist/continuous-event-graph/index.cjs +2 -2
- package/dist/continuous-event-graph/index.cjs.map +1 -1
- package/dist/continuous-event-graph/index.js +2 -2
- package/dist/continuous-event-graph/index.js.map +1 -1
- package/dist/continuous-event-graph/jsonata-sync.cjs +37 -1
- package/dist/execution-refs.cjs +2 -1
- package/dist/execution-refs.cjs.map +1 -1
- package/dist/execution-refs.d.cts +55 -12
- package/dist/execution-refs.d.ts +55 -12
- package/dist/execution-refs.js +2 -1
- package/dist/execution-refs.js.map +1 -1
- package/dist/index.cjs +10 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -10
- package/dist/index.js.map +1 -1
- package/dist/jsonata-sync.cjs +37 -1
- package/dist/server-runtime/index.cjs +9 -0
- package/dist/server-runtime/index.cjs.map +1 -0
- package/dist/server-runtime/index.d.cts +31 -0
- package/dist/server-runtime/index.d.ts +31 -0
- package/dist/server-runtime/index.js +9 -0
- package/dist/server-runtime/index.js.map +1 -0
- package/dist/server-runtime/jsonata-sync.cjs +7623 -0
- package/dist/step-machine-public/index.cjs +3 -0
- package/dist/step-machine-public/index.cjs.map +1 -0
- package/dist/step-machine-public/index.d.cts +166 -0
- package/dist/step-machine-public/index.d.ts +166 -0
- package/dist/step-machine-public/index.js +3 -0
- package/dist/step-machine-public/index.js.map +1 -0
- package/dist/step-machine-public/jsonata-sync.cjs +7623 -0
- package/dist/storage-refs.cjs +2 -2
- package/dist/storage-refs.cjs.map +1 -1
- package/dist/storage-refs.d.cts +6 -6
- package/dist/storage-refs.d.ts +6 -6
- package/dist/storage-refs.js +2 -2
- package/dist/storage-refs.js.map +1 -1
- package/dist/types-CU3DjTKL.d.cts +147 -0
- package/dist/types-HGDTWIun.d.ts +147 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-t4.js +9 -10
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-http-test.js +370 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-http-test.py +398 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-public.js +9 -10
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-server.js +300 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-server.py +617 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-sse-worker.js +48 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker.py +11 -10
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/_board-cli.js +19 -4
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +4 -8
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/init-board-cli.js +6 -10
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/poll-status-cli.js +8 -16
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/reset-board-dir-cli.js +2 -6
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/retrigger-cli.js +4 -8
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/status-cli.js +3 -7
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +4 -8
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/wait-completed-cli.js +7 -16
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/write-prices-cli.js +2 -6
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/_board_pycli.py +13 -3
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/add-cards.py +2 -1
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/init-board.py +2 -1
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/poll-status.py +2 -1
- package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +20 -24
- package/examples/cli/step-machine-cli/portfolio-tracker/run-inline-python-demo-pycli.py +0 -3
- package/examples/cli/step-machine-demo/jsonata-init-board-cli.js +8 -13
- package/examples/cli/step-machine-demo/jsonata-init-board.flow.yaml +33 -9
- package/examples/cli/step-machine-demo/one-step-cli-only.flow.yaml +3 -1
- package/examples/cli/step-machine-demo/step2-double-cli.js +6 -12
- package/examples/cli/step-machine-demo/two-step-math.flow.yaml +66 -4
- package/examples/cli/step-machine-demo/two-step-mixed.flow.yaml +13 -5
- package/examples/example-board/agent-instructions.md +1 -1
- package/examples/example-board/cards/card-my-identity.json +30 -6
- package/examples/example-board/cards/card-portfolio-action.json +24 -6
- package/examples/example-board/cards/card-portfolio-intelligence.json +97 -0
- package/examples/example-board/cards/card-portfolio-risks.json +24 -6
- package/examples/example-board/cards/card-rebalance-impact.json +22 -6
- package/examples/example-board/cards/card-rebalance-sim.json +66 -15
- package/examples/example-board/cards/cardT-market-prices.json +80 -0
- package/examples/example-board/cards/{card-portfolio-value.json → cardT-portfolio-value.json} +38 -10
- package/examples/example-board/cards/cardT-portfolio.json +78 -0
- package/examples/example-board/demo-server-config.json +1 -1
- package/examples/example-board/demo-server.js +383 -69
- package/examples/example-board/demo-shell-localstorage.html +774 -0
- package/examples/example-board/demo-shell-with-server.html +18 -36
- package/examples/example-board/demo-shell.html +5 -4
- package/examples/example-board/demo-task-executor.js +213 -265
- package/package.json +15 -13
- package/step-machine-cli.js +43 -310
- package/board-livecards-server-runtime.js +0 -1513
- package/browser/board-livecards-runtime-client.js +0 -263
- package/dist/pycli/quickjs-board-runtime.global.js +0 -9
- package/dist/pycli/quickjs-board-runtime.global.js.map +0 -1
- package/dist/pycli/quickjs-step-machine-runtime.global.js +0 -5
- package/dist/pycli/quickjs-step-machine-runtime.global.js.map +0 -1
- package/examples/cli/step-machine-demo/two-step-math-handlers.js +0 -32
- package/examples/cli/step-machine-demo/two-step-mixed-handlers.js +0 -24
- package/examples/example-board/cards/card-market-prices.json +0 -56
- package/examples/example-board/cards/card-portfolio.json +0 -44
- package/examples/example-board/demo-shell-browser.html +0 -675
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""portfolio-tracker-http-test.py
|
|
3
|
+
|
|
4
|
+
E2E test for the portfolio-tracker board via HTTP + SSE.
|
|
5
|
+
|
|
6
|
+
Architecture mirrors portfolio-tracker-http-test.js:
|
|
7
|
+
- background SSE consumer thread accumulates NotificationState (NS)
|
|
8
|
+
- main thread drives HTTP PATCH/GET and polls NS for waits
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python portfolio-tracker-http-test.py [--port 7801] [--server node|py]
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
HERE = Path(__file__).resolve().parent
|
|
30
|
+
SERVER_SCRIPT = HERE / "portfolio-tracker-server.js"
|
|
31
|
+
PY_SERVER_SCRIPT = HERE / "portfolio-tracker-server.py"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_args() -> argparse.Namespace:
|
|
35
|
+
parser = argparse.ArgumentParser()
|
|
36
|
+
parser.add_argument("--port", type=int, default=None)
|
|
37
|
+
parser.add_argument("--server", choices=["node", "py"], default="py")
|
|
38
|
+
return parser.parse_args()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ARGS = parse_args()
|
|
42
|
+
PORT = ARGS.port if ARGS.port is not None else (7801 if ARGS.server == "py" else 7800)
|
|
43
|
+
BASE = f"http://127.0.0.1:{PORT}/api/board"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestFailure(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def assert_true(condition: bool, message: str) -> None:
|
|
51
|
+
if not condition:
|
|
52
|
+
raise TestFailure(message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
NS_LOCK = threading.Lock()
|
|
56
|
+
NS = {
|
|
57
|
+
"initialPayload": None,
|
|
58
|
+
"statusSummary": None,
|
|
59
|
+
"statusGeneration": 0,
|
|
60
|
+
"dataObjects": {},
|
|
61
|
+
"computedValues": {},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_frame(payload: dict) -> None:
|
|
66
|
+
with NS_LOCK:
|
|
67
|
+
if isinstance(payload, dict) and payload.get("cardDefinitions") is not None:
|
|
68
|
+
NS["initialPayload"] = payload
|
|
69
|
+
status = payload.get("statusSnapshot") or {}
|
|
70
|
+
if isinstance(status, dict) and isinstance(status.get("summary"), dict):
|
|
71
|
+
NS["statusSummary"] = status["summary"]
|
|
72
|
+
NS["statusGeneration"] += 1
|
|
73
|
+
|
|
74
|
+
dot = payload.get("dataObjectsByToken")
|
|
75
|
+
if isinstance(dot, dict):
|
|
76
|
+
NS["dataObjects"].update(dot)
|
|
77
|
+
|
|
78
|
+
runtimes = payload.get("cardRuntimeById")
|
|
79
|
+
if isinstance(runtimes, dict):
|
|
80
|
+
for card_id, runtime in runtimes.items():
|
|
81
|
+
if not isinstance(runtime, dict):
|
|
82
|
+
continue
|
|
83
|
+
cv = runtime.get("computed_values")
|
|
84
|
+
if isinstance(cv, dict) and cv:
|
|
85
|
+
NS["computedValues"][card_id] = cv
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
isinstance(payload, dict)
|
|
90
|
+
and payload.get("kind") == "notification-batch"
|
|
91
|
+
and isinstance(payload.get("notifications"), list)
|
|
92
|
+
):
|
|
93
|
+
for note in payload["notifications"]:
|
|
94
|
+
if not isinstance(note, dict):
|
|
95
|
+
continue
|
|
96
|
+
kind = note.get("kind")
|
|
97
|
+
if kind == "status" and isinstance((note.get("status") or {}).get("summary"), dict):
|
|
98
|
+
NS["statusSummary"] = note["status"]["summary"]
|
|
99
|
+
NS["statusGeneration"] += 1
|
|
100
|
+
elif kind == "data_object" and isinstance(note.get("key"), str):
|
|
101
|
+
NS["dataObjects"][note["key"]] = note.get("payload")
|
|
102
|
+
elif kind == "computed_values" and isinstance(note.get("cardId"), str):
|
|
103
|
+
NS["computedValues"][note["cardId"]] = note.get("values")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_ns_snapshot() -> dict:
|
|
107
|
+
with NS_LOCK:
|
|
108
|
+
return {
|
|
109
|
+
"initialPayload": NS["initialPayload"],
|
|
110
|
+
"statusSummary": NS["statusSummary"],
|
|
111
|
+
"statusGeneration": NS["statusGeneration"],
|
|
112
|
+
"dataObjects": dict(NS["dataObjects"]),
|
|
113
|
+
"computedValues": dict(NS["computedValues"]),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def wait_until(predicate, timeout_s: float, label: str):
|
|
118
|
+
deadline = time.monotonic() + timeout_s
|
|
119
|
+
while time.monotonic() < deadline:
|
|
120
|
+
try:
|
|
121
|
+
result = predicate()
|
|
122
|
+
except Exception:
|
|
123
|
+
result = None
|
|
124
|
+
if result not in (None, False):
|
|
125
|
+
return result
|
|
126
|
+
time.sleep(0.15)
|
|
127
|
+
|
|
128
|
+
snap = get_ns_snapshot()
|
|
129
|
+
raise TestFailure(
|
|
130
|
+
f"Timeout ({timeout_s}s) waiting for: {label}\n"
|
|
131
|
+
f" NS.statusSummary={json.dumps(snap['statusSummary'])}\n"
|
|
132
|
+
f" dataObjects={json.dumps(sorted(snap['dataObjects'].keys()))}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def wait_for_initial_payload(timeout_s: float = 15.0):
|
|
137
|
+
return wait_until(lambda: get_ns_snapshot()["initialPayload"], timeout_s, "initial SSE payload")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def wait_for_all_completed(timeout_s: float = 60.0, label: str = "all completed"):
|
|
141
|
+
def _pred():
|
|
142
|
+
s = get_ns_snapshot()["statusSummary"]
|
|
143
|
+
if isinstance(s, dict) and s.get("card_count", 0) > 0 and s.get("completed") == s.get("card_count"):
|
|
144
|
+
return s
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
return wait_until(_pred, timeout_s, label)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def wait_for_price_symbols(expected_symbols: list[str], timeout_s: float = 30.0, label: str = "price symbols"):
|
|
151
|
+
expected = ",".join(sorted(expected_symbols))
|
|
152
|
+
|
|
153
|
+
def _pred():
|
|
154
|
+
prices = get_ns_snapshot()["dataObjects"].get("prices")
|
|
155
|
+
if not isinstance(prices, dict):
|
|
156
|
+
return False
|
|
157
|
+
actual = ",".join(sorted(prices.keys()))
|
|
158
|
+
return prices if actual == expected else False
|
|
159
|
+
|
|
160
|
+
return wait_until(_pred, timeout_s, f"{label}: expected [{expected}]")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def http_json(method: str, path: str, payload: dict | None = None) -> tuple[int, object]:
|
|
164
|
+
data = None
|
|
165
|
+
headers = {}
|
|
166
|
+
if payload is not None:
|
|
167
|
+
data = json.dumps(payload).encode("utf-8")
|
|
168
|
+
headers = {
|
|
169
|
+
"Content-Type": "application/json",
|
|
170
|
+
"Content-Length": str(len(data)),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
req = urllib.request.Request(f"{BASE}{path}", data=data, method=method.upper(), headers=headers)
|
|
174
|
+
try:
|
|
175
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
176
|
+
raw = resp.read().decode("utf-8")
|
|
177
|
+
try:
|
|
178
|
+
body = json.loads(raw) if raw else {}
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
body = raw
|
|
181
|
+
return resp.status, body
|
|
182
|
+
except urllib.error.HTTPError as e:
|
|
183
|
+
raw = e.read().decode("utf-8", errors="replace")
|
|
184
|
+
try:
|
|
185
|
+
body = json.loads(raw)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
body = raw
|
|
188
|
+
return e.code, body
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def make_holdings_patch(holdings_map: dict[str, int]) -> dict:
|
|
192
|
+
return {
|
|
193
|
+
"card_data": {
|
|
194
|
+
"holdings": [{"symbol": symbol, "qty": qty} for symbol, qty in holdings_map.items()]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def start_server() -> subprocess.Popen:
|
|
200
|
+
if ARGS.server == "py":
|
|
201
|
+
python = sys.executable or "python"
|
|
202
|
+
cmd = [python, str(PY_SERVER_SCRIPT), "--port", str(PORT), "--reset"]
|
|
203
|
+
else:
|
|
204
|
+
cmd = ["node", str(SERVER_SCRIPT), "--port", str(PORT), "--reset"]
|
|
205
|
+
|
|
206
|
+
proc = subprocess.Popen(
|
|
207
|
+
cmd,
|
|
208
|
+
stdin=subprocess.DEVNULL,
|
|
209
|
+
stdout=subprocess.PIPE,
|
|
210
|
+
stderr=subprocess.PIPE,
|
|
211
|
+
text=True,
|
|
212
|
+
bufsize=1,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _pump_stdout():
|
|
216
|
+
assert proc.stdout is not None
|
|
217
|
+
for line in proc.stdout:
|
|
218
|
+
print(f"[server] {line}", end="")
|
|
219
|
+
|
|
220
|
+
def _pump_stderr():
|
|
221
|
+
assert proc.stderr is not None
|
|
222
|
+
for line in proc.stderr:
|
|
223
|
+
print(f"[server:err] {line}", end="", file=sys.stderr)
|
|
224
|
+
|
|
225
|
+
threading.Thread(target=_pump_stdout, daemon=True).start()
|
|
226
|
+
threading.Thread(target=_pump_stderr, daemon=True).start()
|
|
227
|
+
|
|
228
|
+
deadline = time.monotonic() + 15
|
|
229
|
+
ready = False
|
|
230
|
+
while time.monotonic() < deadline:
|
|
231
|
+
if proc.poll() is not None:
|
|
232
|
+
break
|
|
233
|
+
try:
|
|
234
|
+
with socket.create_connection(("127.0.0.1", PORT), timeout=0.5):
|
|
235
|
+
ready = True
|
|
236
|
+
break
|
|
237
|
+
except OSError:
|
|
238
|
+
time.sleep(0.2)
|
|
239
|
+
|
|
240
|
+
if not ready:
|
|
241
|
+
proc.terminate()
|
|
242
|
+
raise TestFailure("Server startup timeout (15s)")
|
|
243
|
+
|
|
244
|
+
return proc
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def start_sse_consumer(stop_event: threading.Event) -> threading.Thread:
|
|
248
|
+
def _run():
|
|
249
|
+
req = urllib.request.Request(f"{BASE}/sse", headers={"Accept": "text/event-stream"})
|
|
250
|
+
try:
|
|
251
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
252
|
+
block_lines: list[str] = []
|
|
253
|
+
while not stop_event.is_set():
|
|
254
|
+
raw = resp.readline()
|
|
255
|
+
if not raw:
|
|
256
|
+
break
|
|
257
|
+
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
258
|
+
if line == "":
|
|
259
|
+
data_lines = [l[6:] for l in block_lines if l.startswith("data: ")]
|
|
260
|
+
block_lines = []
|
|
261
|
+
if not data_lines:
|
|
262
|
+
continue
|
|
263
|
+
data_text = "\n".join(data_lines)
|
|
264
|
+
try:
|
|
265
|
+
payload = json.loads(data_text)
|
|
266
|
+
except json.JSONDecodeError:
|
|
267
|
+
continue
|
|
268
|
+
apply_frame(payload)
|
|
269
|
+
else:
|
|
270
|
+
block_lines.append(line)
|
|
271
|
+
except Exception as err:
|
|
272
|
+
if not stop_event.is_set():
|
|
273
|
+
print(f"[sse] error: {err}", file=sys.stderr)
|
|
274
|
+
|
|
275
|
+
th = threading.Thread(target=_run, daemon=True)
|
|
276
|
+
th.start()
|
|
277
|
+
return th
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run() -> None:
|
|
281
|
+
print("\n=== portfolio-tracker HTTP E2E test (python) ===")
|
|
282
|
+
print(f"target: {BASE} [server: {ARGS.server}]")
|
|
283
|
+
print("architecture: main-thread (driver) + background SSE consumer\n")
|
|
284
|
+
|
|
285
|
+
server_proc = start_server()
|
|
286
|
+
time.sleep(0.3)
|
|
287
|
+
|
|
288
|
+
sse_stop = threading.Event()
|
|
289
|
+
try:
|
|
290
|
+
print("\n=== Step 1: init-board ===")
|
|
291
|
+
status, _ = http_json("GET", "/init-board")
|
|
292
|
+
assert_true(status == 200, f"init-board returned {status}")
|
|
293
|
+
print("[step1] ok")
|
|
294
|
+
|
|
295
|
+
print("\n=== Step 2: Start SSE consumer ===")
|
|
296
|
+
start_sse_consumer(sse_stop)
|
|
297
|
+
initial_payload = wait_for_initial_payload(15.0)
|
|
298
|
+
snap = get_ns_snapshot()
|
|
299
|
+
print(f"[step2] SSE online — initial payload ({len(initial_payload.get('cardDefinitions', []))} cards)")
|
|
300
|
+
print(f" statusGen={snap['statusGeneration']}, dataObjects={json.dumps(sorted(snap['dataObjects'].keys()))}")
|
|
301
|
+
|
|
302
|
+
print("\n=== T1: Wait for initial completion ===")
|
|
303
|
+
t1_summary = wait_for_all_completed(60.0, "T1 initial drain")
|
|
304
|
+
print(f"[T1] board completed — {json.dumps(t1_summary)}")
|
|
305
|
+
|
|
306
|
+
t1_prices = wait_for_price_symbols(["AAPL", "MSFT"], 30.0, "T1 prices")
|
|
307
|
+
assert_true(all(isinstance(v, (int, float)) for v in t1_prices.values()), "T1: all prices must be numbers")
|
|
308
|
+
t1_table = (get_ns_snapshot()["computedValues"].get("holdings-table") or {}).get("table")
|
|
309
|
+
assert_true(isinstance(t1_table, dict) and isinstance(t1_table.get("rows"), list) and len(t1_table["rows"]) == 2,
|
|
310
|
+
f"T1: expected 2 rows, got {len(t1_table.get('rows', [])) if isinstance(t1_table, dict) else 'n/a'}")
|
|
311
|
+
t1_total = (get_ns_snapshot()["computedValues"].get("portfolio-value") or {}).get("totalValue")
|
|
312
|
+
assert_true(isinstance(t1_total, (int, float)) and t1_total > 0, f"T1: totalValue must be positive, got {t1_total}")
|
|
313
|
+
print(f"[T1] passed: prices=[AAPL,MSFT], rows=2, totalValue={float(t1_total):.2f}")
|
|
314
|
+
|
|
315
|
+
print("\n=== T2a: Update holdings — add GOOG ===")
|
|
316
|
+
status, _ = http_json("PATCH", "/cards/portfolio-form", make_holdings_patch({"AAPL": 50, "MSFT": 30, "GOOG": 100}))
|
|
317
|
+
assert_true(status == 200, f"PATCH portfolio-form returned {status}")
|
|
318
|
+
print("[T2a] PATCH ok — consumer will receive SSE notifications")
|
|
319
|
+
|
|
320
|
+
print("\n=== T2b: Wait for 3-ticker completion ===")
|
|
321
|
+
t2_summary = wait_for_all_completed(60.0, "T2b 3-ticker drain")
|
|
322
|
+
print(f"[T2b] completed — {json.dumps(t2_summary)}")
|
|
323
|
+
|
|
324
|
+
wait_for_price_symbols(["AAPL", "GOOG", "MSFT"], 30.0, "T2b prices")
|
|
325
|
+
t2_table = (get_ns_snapshot()["computedValues"].get("holdings-table") or {}).get("table")
|
|
326
|
+
assert_true(isinstance(t2_table, dict) and isinstance(t2_table.get("rows"), list) and len(t2_table["rows"]) == 3,
|
|
327
|
+
f"T2b: expected 3 rows, got {len(t2_table.get('rows', [])) if isinstance(t2_table, dict) else 'n/a'}")
|
|
328
|
+
t2_total = (get_ns_snapshot()["computedValues"].get("portfolio-value") or {}).get("totalValue")
|
|
329
|
+
assert_true(isinstance(t2_total, (int, float)) and t2_total > 0, "T2b: totalValue must be positive")
|
|
330
|
+
print(f"[T2b] passed: prices=[AAPL,GOOG,MSFT], rows=3, totalValue={float(t2_total):.2f}")
|
|
331
|
+
|
|
332
|
+
print("\n=== T3: Rapid 3x holdings updates ===")
|
|
333
|
+
rapid_updates = [
|
|
334
|
+
{"AAPL": 45, "MSFT": 30, "GOOG": 110, "TSLA": 60},
|
|
335
|
+
{"AAPL": 45, "MSFT": 30, "GOOG": 110, "AMZN": 100},
|
|
336
|
+
{"AAPL": 40, "MSFT": 35, "GOOG": 120, "TSLA": 70},
|
|
337
|
+
]
|
|
338
|
+
for holdings in rapid_updates:
|
|
339
|
+
http_json("PATCH", "/cards/portfolio-form", make_holdings_patch(holdings))
|
|
340
|
+
print("[T3] rapid PATCHes sent — SSE state continues to accumulate")
|
|
341
|
+
|
|
342
|
+
wait_for_all_completed(60.0, "T3 rapid-update drain")
|
|
343
|
+
t3_prices = wait_for_price_symbols(["AAPL", "GOOG", "MSFT", "TSLA"], 30.0, "T3 final prices")
|
|
344
|
+
t3_table = (get_ns_snapshot()["computedValues"].get("holdings-table") or {}).get("table")
|
|
345
|
+
assert_true(isinstance(t3_table, dict) and isinstance(t3_table.get("rows"), list) and len(t3_table["rows"]) == 4,
|
|
346
|
+
f"T3: expected 4 rows, got {len(t3_table.get('rows', [])) if isinstance(t3_table, dict) else 'n/a'}")
|
|
347
|
+
assert_true("AMZN" not in t3_prices, f"T3: AMZN must not be present (got {json.dumps(sorted(t3_prices.keys()))})")
|
|
348
|
+
print(f"[T3] passed: prices={json.dumps(sorted(t3_prices.keys()))}, rows=4, AMZN absent")
|
|
349
|
+
|
|
350
|
+
print("\n=== T4: Cross-verify totalValue ===")
|
|
351
|
+
t4_total = (get_ns_snapshot()["computedValues"].get("portfolio-value") or {}).get("totalValue")
|
|
352
|
+
assert_true(isinstance(t4_total, (int, float)) and t4_total > 0, f"T4: totalValue must be positive, got {t4_total}")
|
|
353
|
+
sum_rows = sum(float(r.get("value", 0)) for r in t3_table["rows"])
|
|
354
|
+
assert_true(abs(sum_rows - float(t4_total)) < 0.01, f"T4: mismatch: sumRows={sum_rows}, totalValue={t4_total}")
|
|
355
|
+
print(f"[T4] passed: totalValue={float(t4_total):.2f}, sumRows={sum_rows:.2f}")
|
|
356
|
+
|
|
357
|
+
print("\n=== T5: board-status HTTP cross-check ===")
|
|
358
|
+
status, t5_body = http_json("GET", "/board-status")
|
|
359
|
+
assert_true(status == 200, f"board-status returned {status}")
|
|
360
|
+
assert_true(isinstance(t5_body, dict), "T5: board-status body is not JSON object")
|
|
361
|
+
t5_summary = ((t5_body.get("statusSnapshot") or {}).get("summary") if isinstance(t5_body, dict) else None)
|
|
362
|
+
assert_true(isinstance(t5_summary, dict), "T5: statusSnapshot.summary missing from board-status")
|
|
363
|
+
assert_true(t5_summary.get("completed") == t5_summary.get("card_count"),
|
|
364
|
+
f"T5: completed={t5_summary.get('completed')} != card_count={t5_summary.get('card_count')}")
|
|
365
|
+
assert_true(t5_summary.get("failed") == 0, f"T5: failed={t5_summary.get('failed')} (expected 0)")
|
|
366
|
+
|
|
367
|
+
http_keys = sorted(((t5_body.get("dataObjectsByToken") or {}).keys()))
|
|
368
|
+
worker_keys = sorted(get_ns_snapshot()["dataObjects"].keys())
|
|
369
|
+
assert_true(http_keys == worker_keys,
|
|
370
|
+
f"T5: HTTP dataObjects keys {http_keys} differ from SSE-accumulated {worker_keys}")
|
|
371
|
+
|
|
372
|
+
print(f"[T5] summary: {json.dumps(t5_summary)}")
|
|
373
|
+
print(f"[T5] HTTP vs SSE dataObjects agree: {json.dumps(worker_keys)}")
|
|
374
|
+
print(f"[T5] statusGen at end: {get_ns_snapshot()['statusGeneration']}")
|
|
375
|
+
print("[T5] all assertions passed")
|
|
376
|
+
|
|
377
|
+
print("\n=== All tests passed ===\n")
|
|
378
|
+
|
|
379
|
+
finally:
|
|
380
|
+
sse_stop.set()
|
|
381
|
+
server_proc.terminate()
|
|
382
|
+
try:
|
|
383
|
+
server_proc.wait(timeout=10)
|
|
384
|
+
except subprocess.TimeoutExpired:
|
|
385
|
+
server_proc.kill()
|
|
386
|
+
server_proc.wait(timeout=5)
|
|
387
|
+
print(f"[portfolio-tracker-http-test.py] server stopped ({ARGS.server})")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
try:
|
|
392
|
+
run()
|
|
393
|
+
except TestFailure as e:
|
|
394
|
+
print(f"\n[ASSERT FAILED] {e}", file=sys.stderr)
|
|
395
|
+
sys.exit(1)
|
|
396
|
+
except KeyboardInterrupt:
|
|
397
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
398
|
+
sys.exit(130)
|
|
@@ -31,6 +31,7 @@ const {
|
|
|
31
31
|
createCardStorePublic,
|
|
32
32
|
createCardStore,
|
|
33
33
|
parseRef,
|
|
34
|
+
serializeRef,
|
|
34
35
|
} = await import(pathToFileURL(_adapterPath).href);
|
|
35
36
|
|
|
36
37
|
const FETCH_PRICES_JS = path.join(__dirname, 'portfolio-tracker-fetch-prices.js');
|
|
@@ -41,9 +42,9 @@ const CARDSTORE_DIR = path.join(_TMP_BASE, 'cardstore');
|
|
|
41
42
|
const BOARDRUNTIME_DIR = path.join(_TMP_BASE, 'boardruntime');
|
|
42
43
|
const OUTPUTS_DIR = path.join(_TMP_BASE, 'outputs');
|
|
43
44
|
|
|
44
|
-
const CARDSTORE_REF =
|
|
45
|
-
const BOARDRUNTIME_REF =
|
|
46
|
-
const OUTPUTS_REF =
|
|
45
|
+
const CARDSTORE_REF = serializeRef({ kind: 'fs-path', value: CARDSTORE_DIR });
|
|
46
|
+
const BOARDRUNTIME_REF = serializeRef({ kind: 'fs-path', value: BOARDRUNTIME_DIR });
|
|
47
|
+
const OUTPUTS_REF = serializeRef({ kind: 'fs-path', value: OUTPUTS_DIR });
|
|
47
48
|
const NOTIFY_CHANNEL = 'yaml-flow-board-notify-portfolio-tracker-public';
|
|
48
49
|
|
|
49
50
|
// ── Card definitions ───────────────────────────────────────────────────────────
|
|
@@ -64,18 +65,16 @@ const CARD_PRICE_FETCH = {
|
|
|
64
65
|
id: 'price-fetch',
|
|
65
66
|
meta: { title: 'Fetch Market Prices' },
|
|
66
67
|
requires: ['holdings'],
|
|
67
|
-
provides: [{ bindTo: 'prices', ref: '
|
|
68
|
+
provides: [{ bindTo: 'prices', ref: 'computed_values.prices' }],
|
|
68
69
|
card_data: {},
|
|
69
|
-
|
|
70
|
-
kind: 'mock-quotes',
|
|
70
|
+
compute: [{
|
|
71
71
|
bindTo: 'prices',
|
|
72
|
-
|
|
73
|
-
projections: { tickers: '$append([], requires.holdings.symbol)' }
|
|
72
|
+
expr: '$merge($map(requires.holdings, function($h){ { $h.symbol: 100 } }))'
|
|
74
73
|
}],
|
|
75
74
|
view: {
|
|
76
75
|
elements: [
|
|
77
76
|
{ kind: 'table', label: 'Market Prices',
|
|
78
|
-
data: { bind: '
|
|
77
|
+
data: { bind: 'computed_values.prices' } }
|
|
79
78
|
]
|
|
80
79
|
}
|
|
81
80
|
};
|
|
@@ -408,7 +407,7 @@ checkResult(
|
|
|
408
407
|
'task-executor-ref': {
|
|
409
408
|
meta: 'task-executor',
|
|
410
409
|
howToRun: 'local-node',
|
|
411
|
-
whatToRun:
|
|
410
|
+
whatToRun: serializeRef({ kind: 'fs-path', value: FETCH_PRICES_JS }),
|
|
412
411
|
},
|
|
413
412
|
},
|
|
414
413
|
}),
|