Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/flow/benchmark/fhir_medplum/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Medplum (Apache-2.0), self-hosted for the second-installation benchmark.
# Synthetic data only. Every image is pinned by digest, every published port
# binds to loopback, and Postgres and Redis publish nothing. Secrets come from
# the fixture's generated state (benchmark/fhir_medplum/fixture.py).
services:
postgres:
image: ${MEDPLUM_PG_IMAGE}
environment:
POSTGRES_USER: medplum
POSTGRES_PASSWORD: ${MEDPLUM_DB_PASSWORD}
command:
- postgres
- -c
- statement_timeout=60000
- -c
- default_transaction_isolation=REPEATABLE READ
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U medplum"]
interval: 5s
timeout: 5s
retries: 30

redis:
image: ${MEDPLUM_REDIS_IMAGE}
command: ["redis-server", "--requirepass", "${MEDPLUM_REDIS_PASSWORD}"]
healthcheck:
test: ["CMD", "redis-cli", "-a", "${MEDPLUM_REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 5s
retries: 30

medplum-server:
image: ${MEDPLUM_SERVER_IMAGE}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: ["env"]
ports:
- "127.0.0.1:${MEDPLUM_SERVER_PORT}:8103"
environment:
MEDPLUM_PORT: 8103
MEDPLUM_BASE_URL: "http://localhost:${MEDPLUM_SERVER_PORT}/"
MEDPLUM_APP_BASE_URL: "http://localhost:${MEDPLUM_APP_PORT}/"
MEDPLUM_STORAGE_BASE_URL: "http://localhost:${MEDPLUM_SERVER_PORT}/storage/"
MEDPLUM_DATABASE_HOST: postgres
MEDPLUM_DATABASE_PORT: 5432
MEDPLUM_DATABASE_DBNAME: medplum
MEDPLUM_DATABASE_USERNAME: medplum
MEDPLUM_DATABASE_PASSWORD: ${MEDPLUM_DB_PASSWORD}
MEDPLUM_REDIS_HOST: redis
MEDPLUM_REDIS_PORT: 6379
MEDPLUM_REDIS_PASSWORD: ${MEDPLUM_REDIS_PASSWORD}
MEDPLUM_BINARY_STORAGE: "file:./binary/"
MEDPLUM_SUPPORT_EMAIL: '"Medplum" <support@example.invalid>'
MEDPLUM_MAX_JSON_SIZE: 1mb
MEDPLUM_MAX_BATCH_SIZE: 50mb
MEDPLUM_VM_CONTEXT_BOTS_ENABLED: "false"
MEDPLUM_ALLOWED_ORIGINS: "http://localhost:${MEDPLUM_APP_PORT}"
MEDPLUM_INTROSPECTION_ENABLED: "true"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8103/healthcheck').then(r => r.ok || process.exit(1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 10s
retries: 60

medplum-app:
image: ${MEDPLUM_APP_IMAGE}
depends_on:
medplum-server:
condition: service_healthy
ports:
- "127.0.0.1:${MEDPLUM_APP_PORT}:3000"
environment:
MEDPLUM_BASE_URL: "http://localhost:${MEDPLUM_SERVER_PORT}/"

volumes:
pgdata:
241 changes: 241 additions & 0 deletions packages/flow/benchmark/fhir_medplum/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"""Teach Medplum's new-patient form: a scripted demonstration, then compile.

PYTHONPATH=. python -m benchmark.fhir_medplum.demo record --out DIR [--values FILE]
PYTHONPATH=. python -m benchmark.fhir_medplum.demo compile --recording DIR --bundle DIR

Signing in and opening the blank form are setup, not part of the
demonstration (``qualify_hooks.open_session``). The demonstration fills the
form and clicks Create. Every action goes through the ``Recorder``, so the
frames and events are what a person's demonstration would leave; record time
uses Playwright locators only to find where to click.

Three choices the form forces:

- Gender is an autocomplete over a ValueSet: the clerk types the code
(``female``), then ArrowDown and Enter take the exact match. Enter alone
submits the whole form.
- Telecom's system is a native select: the clerk types the option
(``phone``, ``email``) into it.
- Controls with nothing readable beside them are reached by keyboard (see
``demonstrate``).
- Birth Date is a native date input; the backend sets an ISO date into it.

Synthetic values only.
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

#: The demonstration's patient (synthetic). Replays bring their own values.
DEFAULT_VALUES = {
"fname": "Rowan",
"lname": "Achterberg",
"DOB": "1979-11-23",
"sex": "female",
"street": "41 Quarry Lane",
"city": "Worcester",
"state_label": "Massachusetts",
"postal_code": "01609",
"phone_home": "508-555-0147",
"email": "rowan.achterberg@example.invalid",
}

SETTLE = {
"settle_timeout_s": 10.0,
"settle_stable_frames": 3,
"settle_interval_s": 0.3,
}

#: Targets are kept below the sticky header and banner, above the fold.
SAFE_TOP, SAFE_BOTTOM, ANCHOR_Y = 170, 800, 420


def _center(recorder: Any, page: Any, locator: Any) -> tuple[int, int]:
"""Bring ``locator`` into the safe band with recorded wheel scrolls."""

last = None
for _ in range(6):
box = locator.bounding_box()
if box is None:
raise RuntimeError("target is not on the page")
mid = box["y"] + box["height"] / 2
if SAFE_TOP <= mid <= SAFE_BOTTOM:
return int(box["x"] + box["width"] / 2), int(mid)
if last is not None and abs(mid - last) < 1 and SAFE_TOP <= mid <= 890:
# The page can't scroll further (the Create button is last).
return int(box["x"] + box["width"] / 2), int(mid)
last = mid
recorder.scroll(0, int(mid - ANCHOR_Y))
page.wait_for_timeout(400)
raise RuntimeError("could not bring the target into view")


def _click(recorder: Any, page: Any, locator: Any) -> None:
recorder.click(*_center(recorder, page, locator))
page.wait_for_timeout(250)


_FOCUSED = (
"() => { const e = document.activeElement; return [e.tagName,"
" e.getAttribute('name') || '', e.getAttribute('data-testid') || '',"
" e.getAttribute('placeholder') || '', (e.innerText || '').trim().slice(0, 30)]; }"
)


def _keys(
recorder: Any, page: Any, key: str, times: int, expect: dict[str, str]
) -> None:
"""Move the focus by keyboard, then check where it landed (not recorded)."""

for _ in range(times):
recorder.press(key)
page.wait_for_timeout(120)
tag, name, testid, placeholder, text = page.evaluate(_FOCUSED)
got = {
"tag": tag,
"name": name,
"testid": testid,
"placeholder": placeholder,
"text": text,
}
wrong = {k: (v, got[k]) for k, v in expect.items() if got[k] != v}
if wrong:
raise RuntimeError(f"focus landed elsewhere after {times} x {key}: {wrong}")


def _keys_until(
recorder: Any, page: Any, key: str, expect: dict[str, str], most: int = 4
) -> int:
"""Press ``key`` until the focus matches ``expect``; the presses are recorded."""

for n in range(1, most + 1):
recorder.press(key)
page.wait_for_timeout(120)
tag, name, testid, placeholder, text = page.evaluate(_FOCUSED)
got = {
"tag": tag,
"name": name,
"testid": testid,
"placeholder": placeholder,
"text": text,
}
if all(got.get(k) == v for k, v in expect.items()):
return n
raise RuntimeError(f"focus never reached {expect} in {most} x {key}")


def demonstrate(recorder: Any, page: Any, values: dict[str, str]) -> None:
"""Fill the new-patient form once and click Create.

Medplum's generic resource form gives several controls nothing to read
beside them: the "Add Name" button, the Telecom selects and value fields,
Gender and Birth Date. A click on them can't carry an identity check, and
clinical-write refuses an unarmed click. So the demonstration reaches
them by keyboard from a click that does carry one, as a keyboard user
would; every keystroke is recorded, and record time checks the focus.
"""

# The first control has nothing beside it, so the demonstration tabs
# from the top of the page to "Add Name" (Space presses a button).
_keys_until(recorder, page, "Tab", {"tag": "BUTTON", "text": "Add Name"}, most=15)
recorder.press("Space")
page.wait_for_timeout(300)
_click(recorder, page, page.get_by_placeholder("Given"))
recorder.type_text(values["fname"], param="fname")
_click(recorder, page, page.get_by_placeholder("Family"))
recorder.type_text(values["lname"], param="lname")

# Each Telecom row: system, use, value, remove, then the Add button.
for system, param in (("phone", "phone_home"), ("email", "email")):
_click(recorder, page, page.get_by_role("button", name="Add Telecom"))
_keys(recorder, page, "Shift+Tab", 4, {"tag": "SELECT", "testid": "system"})
recorder.type_text(system)
_keys(recorder, page, "Tab", 2, {"tag": "INPUT", "placeholder": "Value"})
recorder.type_text(values[param], param=param)

# Birth Date: the row's remove button, Add Telecom, Gender, Birth Date.
# Choosing a gender re-renders the field and drops the focus, so the date
# goes first and Gender last.
_keys(recorder, page, "Tab", 4, {"tag": "INPUT", "name": "birthDate"})
recorder.type_text(values["DOB"], param="DOB")
_keys_until(recorder, page, "Shift+Tab", {"tag": "INPUT", "name": "gender"})
recorder.type_text(values["sex"], param="sex")
page.wait_for_timeout(1200)
recorder.press("ArrowDown")
recorder.press("Enter")
page.wait_for_timeout(300)

_click(recorder, page, page.get_by_role("button", name="Add Address"))
_click(recorder, page, page.get_by_placeholder("Line 1"))
recorder.type_text(values["street"], param="street")
_click(recorder, page, page.get_by_placeholder("City"))
recorder.type_text(values["city"], param="city")
_click(recorder, page, page.get_by_placeholder("State"))
recorder.type_text(values["state_label"], param="state_label")
_click(recorder, page, page.get_by_placeholder("Postal Code"))
recorder.type_text(values["postal_code"], param="postal_code")

_click(recorder, page, page.get_by_role("button", name="Create"))
page.wait_for_url(lambda url: "/Patient/new" not in url, timeout=30_000)
page.wait_for_timeout(1500)


def cmd_record(args: argparse.Namespace) -> int:
from types import SimpleNamespace

from benchmark.fhir_medplum import qualify_hooks
from openadapt_flow.recorder import Recorder

out = Path(args.out)
if out.exists():
raise SystemExit(f"recording path already exists: {out}")
values = dict(DEFAULT_VALUES)
if args.values:
values.update(json.loads(Path(args.values).read_text()))
config = SimpleNamespace(app=SimpleNamespace(viewport=(1280, 900)))
backend, close = qualify_hooks.open_session(config)
try:
recorder = Recorder(backend, out, app_url=backend.page.url, **SETTLE)
demonstrate(recorder, backend.page, values)
print("recording:", recorder.finish())
print("saved:", backend.page.url)
finally:
close()
return 0


def cmd_compile(args: argparse.Namespace) -> int:
from openadapt_flow.compiler import compile_recording

bundle = Path(args.bundle)
if bundle.exists():
raise SystemExit(f"bundle path already exists: {bundle}")
workflow = compile_recording(
Path(args.recording), bundle, name="medplum-new-patient"
)
print("bundle:", bundle, "steps:", len(workflow.steps))
print("params:", sorted(workflow.params) if workflow.params else [])
return 0


def main(argv: Any = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("record")
p.add_argument("--out", required=True)
p.add_argument("--values", default=None, help="JSON file of field values")
p.set_defaults(func=cmd_record)
p = sub.add_parser("compile")
p.add_argument("--recording", required=True)
p.add_argument("--bundle", required=True)
p.set_defaults(func=cmd_compile)
args = parser.parse_args(argv)
return args.func(args)


if __name__ == "__main__":
raise SystemExit(main())
13 changes: 13 additions & 0 deletions packages/flow/benchmark/fhir_medplum/environment.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"schema_version": 1,
"generated_for": "OpenAdapt second-installation benchmark: Medplum (FHIR R4) with synthetic patients",
"license": "Apache-2.0 (Medplum); images pulled at run time, never stored in this repository",
"services": {
"server": "medplum/medplum-server:5.2.8@sha256:d70ee91bcf15aa5302c91ad3875f96a6f547f01c2e24c3499d9203f084321c0a",
"app": "medplum/medplum-app:5.2.8@sha256:4a4a4d411e28260e83d8f2802ea24b57156aa53096f5013813969af48b8a886d",
"postgres": "postgres:16@sha256:9e2cf6287f25c3151f4c9421ccce2b17f3e60061c111a29165e0d2cdd9ff0a97",
"redis": "redis:7@sha256:44960b3100c48159a66d28c18b0588c193492e25f7551d60e51dd464fc3eca5b"
},
"platforms": "every image publishes linux/arm64 and linux/amd64",
"project": "openadapt-si-medplum"
}
Loading
Loading