update 33.1 50
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -9,6 +9,13 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Built library bundles (regenerate with `pnpm build` in the package).
|
||||||
|
# The dist is what a container boots; it is an artifact, not source.
|
||||||
|
dist/
|
||||||
|
|
||||||
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
||||||
gen/
|
gen/
|
||||||
|
|
||||||
|
|||||||
73
CLAUDE.md
73
CLAUDE.md
@@ -51,20 +51,19 @@ spr/
|
|||||||
│ └── amar/ # Amar room config
|
│ └── amar/ # Amar room config
|
||||||
│ ├── config.json
|
│ ├── config.json
|
||||||
│ ├── data/
|
│ ├── data/
|
||||||
│ ├── artery/ # Amar-specific (merged into output)
|
│ ├── soleprint/ # Room overlay — merged over soleprint/ on build
|
||||||
│ │ └── shunts/amar/
|
│ │ ├── artery/ # room shunts, pulses
|
||||||
│ ├── atlas/ # Amar-specific books
|
│ │ │ └── shunts/amar/
|
||||||
│ │ └── books/
|
│ │ ├── atlas/ # room books
|
||||||
│ ├── station/ # Amar-specific tools config
|
│ │ │ └── books/
|
||||||
│ │ └── tools/datagen/
|
│ │ ├── station/ # room tool configs
|
||||||
|
│ │ │ └── tools/datagen/
|
||||||
|
│ │ └── nginx/
|
||||||
|
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
|
||||||
│ ├── link/ # Bridge to managed app
|
│ ├── link/ # Bridge to managed app
|
||||||
│ ├── soleprint/ # Soleprint docker config
|
│ └── amar/ # The managed app itself
|
||||||
│ ├── databrowse/
|
|
||||||
│ ├── tester/
|
|
||||||
│ ├── monitors/
|
|
||||||
│ └── models/
|
|
||||||
│
|
│
|
||||||
├── ctrl/ # Build/run scripts
|
├── ctrl/ # Build/run scripts (see Build & Run)
|
||||||
│
|
│
|
||||||
└── gen/ # Built instances (gitignored)
|
└── gen/ # Built instances (gitignored)
|
||||||
├── standalone/
|
├── standalone/
|
||||||
@@ -100,26 +99,38 @@ Each room in `cfg/` has:
|
|||||||
- `config.json` - Framework branding/terminology
|
- `config.json` - Framework branding/terminology
|
||||||
- `data/` - Data files (veins.json, shunts.json, etc.)
|
- `data/` - Data files (veins.json, shunts.json, etc.)
|
||||||
|
|
||||||
Room-specific system configs (merged into output):
|
Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
|
||||||
- `artery/` - Room-specific shunts, pulses
|
the core `soleprint/` tree at build time:
|
||||||
- `atlas/` - Room-specific books
|
- `soleprint/artery/` - Room-specific shunts, pulses
|
||||||
- `station/` - Room-specific tool configs (datagen, tester tests, etc.)
|
- `soleprint/atlas/` - Room-specific books
|
||||||
|
- `soleprint/station/` - Room-specific tool configs (datagen generators, tester tests)
|
||||||
|
|
||||||
|
A room's own lifecycle scripts live in `cfg/<room>/ctrl/` and land in
|
||||||
|
`gen/<room>/ctrl/` — that is what `make start <room>` dispatches to.
|
||||||
|
|
||||||
## Build & Run
|
## Build & Run
|
||||||
|
|
||||||
|
`make` is the front door — one target per `ctrl/` script, with the subcommand as an
|
||||||
|
argument (`make cluster up`, not `make cluster-up`). The logic lives in the scripts,
|
||||||
|
never in the Makefile. `make help` lists every target.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
make # = make help
|
||||||
python build.py # -> gen/standalone/
|
make build [room|all|models] # -> gen/<room>/ (default: standalone)
|
||||||
python build.py --cfg amar # -> gen/amar/
|
make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
|
||||||
python build.py --all # -> all rooms
|
make stop [room]
|
||||||
|
make cluster [up|down|status] # the shared `spr` kind cluster
|
||||||
|
make component [list|sync|watch|publish|diff]
|
||||||
|
make deploy [--build|--sync-only]
|
||||||
|
```
|
||||||
|
|
||||||
# Run bare-metal
|
Every script stays runnable on its own — the standalone rule holds:
|
||||||
cd gen/standalone && python run.py
|
|
||||||
|
|
||||||
# Using ctrl scripts
|
```bash
|
||||||
./ctrl/build.sh [room]
|
python build.py --cfg amar # -> gen/amar/
|
||||||
./ctrl/start.sh [room] [-d]
|
cd gen/standalone && python run.py # bare-metal
|
||||||
./ctrl/stop.sh [room]
|
./ctrl/kind-up.sh # still works directly
|
||||||
|
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding a New Room
|
## Adding a New Room
|
||||||
@@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data
|
|||||||
cp cfg/standalone/config.json cfg/newroom/
|
cp cfg/standalone/config.json cfg/newroom/
|
||||||
cp -r cfg/standalone/data/* cfg/newroom/data/
|
cp -r cfg/standalone/data/* cfg/newroom/data/
|
||||||
|
|
||||||
# Add room-specific configs as needed:
|
# Add room-specific configs as needed (note the soleprint/ overlay level):
|
||||||
# cfg/newroom/artery/shunts/...
|
# cfg/newroom/soleprint/artery/shunts/...
|
||||||
# cfg/newroom/atlas/books/...
|
# cfg/newroom/soleprint/atlas/books/...
|
||||||
# cfg/newroom/station/tools/...
|
# cfg/newroom/soleprint/station/tools/datagen/<name>.py
|
||||||
|
|
||||||
python build.py --cfg newroom
|
make build newroom
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|||||||
62
Makefile
Normal file
62
Makefile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Thin control Makefile — one target per ctrl/ script, and the subcommand is an
|
||||||
|
# argument rather than a second target: `make cluster down`, not `make cluster-down`.
|
||||||
|
#
|
||||||
|
# The logic lives in the scripts, never here. Each target maps to exactly one
|
||||||
|
# file, and that file holds the variants:
|
||||||
|
#
|
||||||
|
# make cluster up -> ctrl/cluster.sh up
|
||||||
|
# make build amar -> ctrl/build.sh amar
|
||||||
|
#
|
||||||
|
# Bare words pass straight through. Anything starting with a dash would be
|
||||||
|
# swallowed by make itself, so pass those via ARGS instead:
|
||||||
|
#
|
||||||
|
# make component ARGS="publish soleprint-ui /tmp/out --dist"
|
||||||
|
# make deploy ARGS="--build"
|
||||||
|
#
|
||||||
|
# Every script stays runnable on its own (./ctrl/kind-up.sh still works, and each
|
||||||
|
# built room keeps its own gen/<room>/ctrl/*.sh) — the standalone rule holds, and
|
||||||
|
# this only saves typing.
|
||||||
|
#
|
||||||
|
# Start with: make build && make start
|
||||||
|
|
||||||
|
# The room to act on when none is named. Rooms live in cfg/ and build into gen/.
|
||||||
|
ROOM ?= standalone
|
||||||
|
PYTHON ?= python3
|
||||||
|
export PYTHON
|
||||||
|
|
||||||
|
# Words after the target become the script's subcommand. Make would otherwise
|
||||||
|
# treat them as goals of their own, so each gets a no-op rule.
|
||||||
|
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
|
||||||
|
ifneq ($(ARGS),)
|
||||||
|
$(eval $(ARGS):;@:)
|
||||||
|
endif
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.PHONY: help build start stop cluster deploy component
|
||||||
|
|
||||||
|
help: ## list targets
|
||||||
|
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
|
||||||
|
|
||||||
|
# ── rooms ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build: ## build a room into gen/ [<room>|all|models]
|
||||||
|
bash ctrl/build.sh $(or $(ARGS),$(ROOM))
|
||||||
|
|
||||||
|
start: ## run a built room [<room>] [-d] [--build]
|
||||||
|
bash ctrl/start.sh $(or $(ARGS),$(ROOM))
|
||||||
|
|
||||||
|
stop: ## stop a running room [<room>]
|
||||||
|
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
||||||
|
|
||||||
|
# ── cluster ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cluster: ## shared kind cluster [up|down|status] (default status)
|
||||||
|
bash ctrl/cluster.sh $(or $(ARGS),status)
|
||||||
|
|
||||||
|
# ── distribution ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
component: ## publish components [list|sync|watch|publish|diff]
|
||||||
|
$(PYTHON) ctrl/spr.py $(or $(ARGS),list)
|
||||||
|
|
||||||
|
deploy: ## push standalone to the server [--build|--sync-only]
|
||||||
|
bash ctrl/deploy.sh $(ARGS)
|
||||||
6
build.py
6
build.py
@@ -419,7 +419,11 @@ def main():
|
|||||||
elif args.all:
|
elif args.all:
|
||||||
build(SPR_ROOT / "gen" / "standalone", None)
|
build(SPR_ROOT / "gen" / "standalone", None)
|
||||||
for room in (SPR_ROOT / "cfg").iterdir():
|
for room in (SPR_ROOT / "cfg").iterdir():
|
||||||
if room.is_dir() and room.name not in ("__pycache__", "standalone"):
|
# cfg/ is itself a git repo and rooms may carry dot-dirs — skip them,
|
||||||
|
# or --all tries to build ".git" as a room.
|
||||||
|
if room.name.startswith(".") or room.name == "__pycache__":
|
||||||
|
continue
|
||||||
|
if room.is_dir() and room.name != "standalone":
|
||||||
build(SPR_ROOT / "gen" / room.name, room.name)
|
build(SPR_ROOT / "gen" / room.name, room.name)
|
||||||
else:
|
else:
|
||||||
if args.output:
|
if args.output:
|
||||||
|
|||||||
37
ctrl/build.sh
Executable file
37
ctrl/build.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build a room into gen/ — thin wrapper over build.py.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./ctrl/build.sh # standalone -> gen/standalone/
|
||||||
|
# ./ctrl/build.sh amar # amar -> gen/amar/
|
||||||
|
# ./ctrl/build.sh all # every room under cfg/
|
||||||
|
# ./ctrl/build.sh models # only regenerate models
|
||||||
|
#
|
||||||
|
# build.py holds the logic; this exists so `make build` has exactly one
|
||||||
|
# script to call, and so the room name is a plain argument.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
ROOM="${1:-standalone}"
|
||||||
|
|
||||||
|
case "$ROOM" in
|
||||||
|
all) exec "$PYTHON" build.py --all ;;
|
||||||
|
models) exec "$PYTHON" build.py --models ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ ! -d "cfg/$ROOM" ]]; then
|
||||||
|
echo "No such room: cfg/$ROOM" >&2
|
||||||
|
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# standalone is build.py's default and takes no --cfg
|
||||||
|
if [[ "$ROOM" == "standalone" ]]; then
|
||||||
|
exec "$PYTHON" build.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$PYTHON" build.py --cfg "$ROOM"
|
||||||
24
ctrl/cluster.sh
Executable file
24
ctrl/cluster.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# The shared `spr` kind cluster [up|down|status] (default status).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./ctrl/cluster.sh up # create the cluster (no-op if it exists)
|
||||||
|
# ./ctrl/cluster.sh down # delete it (drops every room's namespace)
|
||||||
|
# ./ctrl/cluster.sh status # what's running on it
|
||||||
|
#
|
||||||
|
# One target, one script — the variants live here. The kind-*.sh files stay
|
||||||
|
# exactly as they are and remain runnable on their own; this only dispatches.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
up) exec "$SCRIPT_DIR/kind-up.sh" ;;
|
||||||
|
down) exec "$SCRIPT_DIR/kind-down.sh" ;;
|
||||||
|
status) exec "$SCRIPT_DIR/kind-status.sh" ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown subcommand: $1" >&2
|
||||||
|
echo "Usage: cluster.sh [up|down|status]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
47
ctrl/spr.py
47
ctrl/spr.py
@@ -11,6 +11,7 @@ Usage:
|
|||||||
python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework
|
python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework
|
||||||
python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop
|
python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop
|
||||||
python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework
|
python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework
|
||||||
|
python ctrl/spr.py publish soleprint-ui /tmp/out --dist # built bundle only
|
||||||
python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework
|
python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -194,6 +195,37 @@ def cmd_list(args):
|
|||||||
log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"])
|
log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"])
|
||||||
|
|
||||||
|
|
||||||
|
def publish_dist(source, dest):
|
||||||
|
"""Copy only the built bundle — dist/ plus the manifest and any docs.
|
||||||
|
|
||||||
|
The artifact-only form: a consumer gets something that runs, not the
|
||||||
|
sources. Deliberately NOT a variant of copy_tree, which walks the whole
|
||||||
|
tree and strips dist; here dist is the entire point.
|
||||||
|
"""
|
||||||
|
dist = source / "dist"
|
||||||
|
if not dist.is_dir() or not any(dist.iterdir()):
|
||||||
|
log.error("no build at %s", dist)
|
||||||
|
log.info("build it first: cd %s && pnpm build", source)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
for item in dist.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
target = dest / "dist" / item.relative_to(dist)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(item, target)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# The manifest travels too, or the bundle's entry points aren't resolvable.
|
||||||
|
for name in ("package.json", "README.md", "LICENSE"):
|
||||||
|
if (source / name).is_file():
|
||||||
|
shutil.copy2(source / name, dest / name)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def cmd_publish(args):
|
def cmd_publish(args):
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
comp_type, source = resolve_component(registry, args.component)
|
comp_type, source = resolve_component(registry, args.component)
|
||||||
@@ -202,12 +234,17 @@ def cmd_publish(args):
|
|||||||
if dest.exists():
|
if dest.exists():
|
||||||
shutil.rmtree(dest)
|
shutil.rmtree(dest)
|
||||||
|
|
||||||
|
if args.dist:
|
||||||
|
count = publish_dist(source, dest)
|
||||||
|
mode = "published-dist"
|
||||||
|
else:
|
||||||
count = copy_tree(source, dest)
|
count = copy_tree(source, dest)
|
||||||
write_stamp(dest, args.component, comp_type, source, "published")
|
mode = "published"
|
||||||
|
write_stamp(dest, args.component, comp_type, source, mode)
|
||||||
|
|
||||||
version = get_version(comp_type, source)
|
version = get_version(comp_type, source)
|
||||||
sha = get_sha()
|
sha = get_sha()
|
||||||
log.info("%s v%s (%s) -> %s (%d files)", args.component, version, sha, dest, count)
|
log.info("%s v%s (%s) -> %s (%d files, %s)", args.component, version, sha, dest, count, mode)
|
||||||
|
|
||||||
|
|
||||||
def cmd_sync(args):
|
def cmd_sync(args):
|
||||||
@@ -306,6 +343,12 @@ def main():
|
|||||||
p = sub.add_parser(cmd)
|
p = sub.add_parser(cmd)
|
||||||
p.add_argument("component", help="component name")
|
p.add_argument("component", help="component name")
|
||||||
p.add_argument("dest", help="target folder path")
|
p.add_argument("dest", help="target folder path")
|
||||||
|
if cmd == "publish":
|
||||||
|
p.add_argument(
|
||||||
|
"--dist",
|
||||||
|
action="store_true",
|
||||||
|
help="ship only the built bundle (dist/ + manifest), not the sources",
|
||||||
|
)
|
||||||
|
|
||||||
p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)")
|
p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)")
|
||||||
p.add_argument("component", help="component name")
|
p.add_argument("component", help="component name")
|
||||||
|
|||||||
35
ctrl/start.sh
Executable file
35
ctrl/start.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start a built room by dispatching to its own ctrl/start.sh in gen/.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./ctrl/start.sh # standalone, foreground
|
||||||
|
# ./ctrl/start.sh amar -d # amar, detached
|
||||||
|
# ./ctrl/start.sh sample --build # flags pass straight through
|
||||||
|
#
|
||||||
|
# Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays
|
||||||
|
# runnable on its own — this only saves cd'ing there and picks the default room.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
ROOM="standalone"
|
||||||
|
if [[ $# -gt 0 && "$1" != -* ]]; then
|
||||||
|
ROOM="$1"
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOM_DIR="$ROOT_DIR/gen/$ROOM"
|
||||||
|
|
||||||
|
if [[ ! -d "$ROOM_DIR" ]]; then
|
||||||
|
echo "Room '$ROOM' is not built — run: ./ctrl/build.sh $ROOM" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$ROOM_DIR/ctrl/start.sh" ]]; then
|
||||||
|
echo "No start script at gen/$ROOM/ctrl/start.sh" >&2
|
||||||
|
echo "(rebuild the room, or start it by hand from $ROOM_DIR)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$ROOM_DIR/ctrl/start.sh" "$@"
|
||||||
27
ctrl/stop.sh
Executable file
27
ctrl/stop.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stop a running room by dispatching to its own ctrl/stop.sh in gen/.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./ctrl/stop.sh # standalone
|
||||||
|
# ./ctrl/stop.sh amar # a named room
|
||||||
|
#
|
||||||
|
# Mirror of ctrl/start.sh — the room's own script does the work.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
ROOM="standalone"
|
||||||
|
if [[ $# -gt 0 && "$1" != -* ]]; then
|
||||||
|
ROOM="$1"
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOM_DIR="$ROOT_DIR/gen/$ROOM"
|
||||||
|
|
||||||
|
if [[ ! -x "$ROOM_DIR/ctrl/stop.sh" ]]; then
|
||||||
|
echo "No stop script at gen/$ROOM/ctrl/stop.sh — nothing to stop." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$ROOM_DIR/ctrl/stop.sh" "$@"
|
||||||
@@ -4,7 +4,22 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./style.css": "./dist/style.css",
|
||||||
|
"./theme.css": "./src/theme.css",
|
||||||
|
"./tokens.css": "./src/tokens.css",
|
||||||
|
"./base.css": "./src/base.css",
|
||||||
|
"./dist/*": "./dist/*",
|
||||||
|
"./src/*": "./src/*"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
|||||||
8
soleprint/common/ui/pnpm-workspace.yaml
Normal file
8
soleprint/common/ui/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# pnpm 10+ moved settings here out of package.json.
|
||||||
|
#
|
||||||
|
# esbuild (vite's bundler) and vue-demi (pinia) need their postinstall to link
|
||||||
|
# platform binaries. Without this pnpm refuses to run them and exits non-zero,
|
||||||
|
# which makes every `pnpm typecheck` / `test` / `build` fail before it starts.
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
vue-demi: true
|
||||||
69
soleprint/common/ui/src/base.css
Normal file
69
soleprint/common/ui/src/base.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/* Framework base layer — element defaults written against tokens.css.
|
||||||
|
*
|
||||||
|
* This was duplicated byte-for-byte in every app's own styles.css (doocus-app,
|
||||||
|
* meetus-app). It is theme, not app, so it ships with the framework: an app that
|
||||||
|
* imports the framework gets a consistent shell without restating it.
|
||||||
|
*
|
||||||
|
* Retheme by replacing tokens.css — every value here resolves through it. */
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--surface-0);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: var(--panel-border);
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-0);
|
||||||
|
border: var(--panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
// Framework public API
|
// Framework public API
|
||||||
|
|
||||||
|
// Theme (tokens + base layer). Imported here so the visual identity is part of
|
||||||
|
// the bundle rather than something each consumer must remember to wire up —
|
||||||
|
// every component below styles itself with the variables it defines.
|
||||||
|
import './theme.css'
|
||||||
|
|
||||||
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||||
export { SSEDataSource } from './datasources/SSEDataSource'
|
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||||
|
|||||||
11
soleprint/common/ui/src/theme.css
Normal file
11
soleprint/common/ui/src/theme.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/* The whole visual identity in one import: design tokens + element defaults.
|
||||||
|
*
|
||||||
|
* index.ts imports this, so the theme travels with the bundle and cannot be
|
||||||
|
* forgotten — a dist that renders unthemed is a broken dist, not an unbranded
|
||||||
|
* one, because every component styles itself with var(--surface-0) and friends.
|
||||||
|
*
|
||||||
|
* Consumers of the built package import `soleprint-ui/style.css`.
|
||||||
|
* To retheme, override the variables from tokens.css after this import. */
|
||||||
|
|
||||||
|
@import './tokens.css';
|
||||||
|
@import './base.css';
|
||||||
37
soleprint/common/ui/vite.config.ts
Normal file
37
soleprint/common/ui/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library build — emits dist/soleprint-ui.js plus a single dist/style.css
|
||||||
|
* containing the theme (tokens + base) and every component's scoped styles.
|
||||||
|
*
|
||||||
|
* The CSS is the point as much as the JS: components style themselves with
|
||||||
|
* var(--surface-0) and friends, so a bundle shipped without it renders broken.
|
||||||
|
*
|
||||||
|
* Peer packages are external so a consuming app resolves ONE copy of vue —
|
||||||
|
* two Vue instances break reactivity and provide/inject in ways that are
|
||||||
|
* miserable to debug.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'soleprint-ui.js',
|
||||||
|
cssFileName: 'style',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['vue', 'pinia', '@vue-flow/core', 'uplot'],
|
||||||
|
output: {
|
||||||
|
globals: { vue: 'Vue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,164 +1,87 @@
|
|||||||
# Datagen - Test Data Generator
|
# Datagen — Test Data Generator
|
||||||
|
|
||||||
Pluggable test data generators for various domain models and external APIs.
|
Room-specific test data generators, discovered and served by the hub.
|
||||||
|
|
||||||
## Purpose
|
The core ships the base class and the API only. **Generators themselves belong to a
|
||||||
|
room** (`cfg/<room>/soleprint/station/tools/datagen/`) and are merged into the built
|
||||||
- Generate realistic test data for Amar domain models
|
instance — so no client's domain vocabulary lives here.
|
||||||
- Generate mock API responses for external services (MercadoPago, etc.)
|
|
||||||
- Can be plugged into any nest (test suites, mock veins, seeders)
|
|
||||||
- Domain-agnostic and reusable
|
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
datagen/
|
datagen/
|
||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── amar.py # Amar domain models (petowner, pet, cart, etc.)
|
├── base.py # BaseDataGenerator — the contract
|
||||||
├── mercadopago.py # MercadoPago API responses
|
├── api.py # FastAPI router, mounted at /tools/datagen
|
||||||
└── README.md # This file
|
├── templates/
|
||||||
|
│ └── index.html # browser UI
|
||||||
|
└── README.md # this file
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Writing a generator
|
||||||
|
|
||||||
### In Tests
|
Subclass `BaseDataGenerator` and name each method after the model it generates. The
|
||||||
|
method name *is* the model name — there is no registry to update.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from ward.tools.datagen.amar import AmarDataGenerator
|
from faker import Faker
|
||||||
|
|
||||||
def test_petowner_creation():
|
# Guarded so the file also runs on its own, outside a built instance —
|
||||||
owner_data = AmarDataGenerator.petowner(address="Av. Corrientes 1234")
|
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
|
||||||
assert owner_data["address"] == "Av. Corrientes 1234"
|
try:
|
||||||
```
|
from station.tools.datagen.base import BaseDataGenerator
|
||||||
|
except ImportError:
|
||||||
|
class BaseDataGenerator:
|
||||||
|
pass
|
||||||
|
|
||||||
### In Mock Veins
|
fake = Faker()
|
||||||
|
|
||||||
```python
|
|
||||||
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
|
|
||||||
|
|
||||||
@router.post("/v1/preferences")
|
|
||||||
async def create_preference(request: dict):
|
|
||||||
# Generate mock response
|
|
||||||
return MercadoPagoDataGenerator.preference(
|
|
||||||
description=request["items"][0]["title"],
|
|
||||||
total=request["items"][0]["unit_price"],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### In Seeders
|
class MyRoomGenerator(BaseDataGenerator):
|
||||||
|
def user(self, **kwargs):
|
||||||
|
return {"id": fake.uuid4(), "name": fake.name(), **kwargs}
|
||||||
|
|
||||||
```python
|
def product(self, category=None, **kwargs):
|
||||||
from ward.tools.datagen.amar import AmarDataGenerator
|
return {"id": fake.uuid4(), "name": fake.word(), "category": category, **kwargs}
|
||||||
|
|
||||||
# Create 10 test pet owners
|
|
||||||
for i in range(10):
|
|
||||||
owner = AmarDataGenerator.petowner(is_guest=False)
|
|
||||||
# Save to database...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design Principles
|
Drop it in `cfg/<room>/soleprint/station/tools/datagen/<name>.py` and rebuild the room.
|
||||||
|
|
||||||
1. **Pluggable**: Can be used anywhere, not tied to specific frameworks
|
|
||||||
2. **Realistic**: Generated data matches real-world patterns
|
|
||||||
3. **Flexible**: Override any field via `**overrides` parameter
|
|
||||||
4. **Domain-focused**: Each generator focuses on a specific domain
|
|
||||||
5. **Stateless**: Pure functions, no global state
|
|
||||||
|
|
||||||
## Generators
|
**Discovery rules** (`api.py:_load_generators`): every `*.py` in the tool directory is
|
||||||
|
scanned except `base.py`, `api.py`, and anything starting with `_`. The first class whose
|
||||||
|
name ends in `Generator` (and isn't `BaseDataGenerator`) is instantiated.
|
||||||
|
|
||||||
### AmarDataGenerator (amar.py)
|
## What the base class gives you
|
||||||
|
|
||||||
Generates data for Amar platform:
|
| Method | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `generate(model, count=1, **kwargs)` | Call the matching method `count` times; raises `ValueError` listing available models if there's no match |
|
||||||
|
| `available_models()` | Method names, minus the reserved ones — i.e. the models you support |
|
||||||
|
| `schema()` | Optional override returning a graphgen-compatible schema; `None` by default |
|
||||||
|
|
||||||
- `petowner()` - Pet owners (guest and registered)
|
## HTTP API
|
||||||
- `pet()` - Pets with species, age, etc.
|
|
||||||
- `cart()` - Shopping carts
|
|
||||||
- `service_request()` - Service requests
|
|
||||||
- `filter_services()` - Service filtering by species/neighborhood
|
|
||||||
- `filter_categories()` - Category filtering
|
|
||||||
- `calculate_cart_summary()` - Cart totals with discounts
|
|
||||||
|
|
||||||
### MercadoPagoDataGenerator (mercadopago.py)
|
Mounted at `/tools/datagen`:
|
||||||
|
|
||||||
Generates MercadoPago API responses:
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/generators` | loaded generator files and their models |
|
||||||
|
| `GET /api/models` | models for one generator (`?generator=<name>`) |
|
||||||
|
| `POST /api/generate` | `{model, count, generator?, kwargs}` → generated items |
|
||||||
|
| `GET /api/schema` | graphgen-compatible schema, when the generator exposes one |
|
||||||
|
|
||||||
- `preference()` - Checkout Pro preference
|
With one generator loaded, `generator` can be omitted everywhere — the only one is used.
|
||||||
- `payment()` - Payment (Checkout API/Bricks)
|
|
||||||
- `merchant_order()` - Merchant order
|
|
||||||
- `oauth_token()` - OAuth token exchange
|
|
||||||
- `webhook_notification()` - Webhook payloads
|
|
||||||
|
|
||||||
## Examples
|
```bash
|
||||||
|
curl -X POST localhost:12000/tools/datagen/api/generate \
|
||||||
### Generate a complete turnero flow
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"model": "user", "count": 3}'
|
||||||
```python
|
|
||||||
from ward.tools.datagen.amar import AmarDataGenerator
|
|
||||||
|
|
||||||
# Step 1: Guest pet owner
|
|
||||||
owner = AmarDataGenerator.petowner(
|
|
||||||
address="Av. Santa Fe 1234, Palermo",
|
|
||||||
is_guest=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 2: Pet
|
|
||||||
pet = AmarDataGenerator.pet(
|
|
||||||
owner_id=owner["id"],
|
|
||||||
name="Luna",
|
|
||||||
species="DOG",
|
|
||||||
age_value=3,
|
|
||||||
age_unit="years"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 3: Cart
|
|
||||||
cart = AmarDataGenerator.cart(owner_id=owner["id"])
|
|
||||||
|
|
||||||
# Step 4: Add services to cart
|
|
||||||
services = AmarDataGenerator.filter_services(
|
|
||||||
species="DOG",
|
|
||||||
neighborhood_id=owner["neighborhood"]["id"]
|
|
||||||
)
|
|
||||||
|
|
||||||
cart_with_items = AmarDataGenerator.calculate_cart_summary(
|
|
||||||
cart,
|
|
||||||
items=[
|
|
||||||
{"service_id": services[0]["id"], "price": services[0]["price"], "quantity": 1, "pet_id": pet["id"]},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 5: Service request
|
|
||||||
request = AmarDataGenerator.service_request(cart_id=cart["id"])
|
|
||||||
```
|
|
||||||
|
|
||||||
### Generate a payment flow
|
|
||||||
|
|
||||||
```python
|
|
||||||
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
|
|
||||||
|
|
||||||
# Create preference
|
|
||||||
pref = MercadoPagoDataGenerator.preference(
|
|
||||||
description="Visita a domicilio",
|
|
||||||
total=95000,
|
|
||||||
external_reference="SR-12345"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Simulate payment
|
|
||||||
payment = MercadoPagoDataGenerator.payment(
|
|
||||||
transaction_amount=95000,
|
|
||||||
description="Visita a domicilio",
|
|
||||||
status="approved",
|
|
||||||
application_fee=45000 # Platform fee (split payment)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Webhook notification
|
|
||||||
webhook = MercadoPagoDataGenerator.webhook_notification(
|
|
||||||
topic="payment",
|
|
||||||
resource_id=str(payment["id"])
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future Generators
|
## Design principles
|
||||||
|
|
||||||
- `google.py` - Google API responses (Calendar, Sheets)
|
1. **Room-owned** — domain vocabulary lives in `cfg/<room>/`, never in core.
|
||||||
- `whatsapp.py` - WhatsApp API responses
|
2. **Convention over registration** — a method name is a model name.
|
||||||
- `slack.py` - Slack API responses
|
3. **Flexible** — any field is overridable through `**kwargs`.
|
||||||
|
4. **Stateless** — no global state between calls.
|
||||||
|
5. **Standalone** — usable directly as a Python class, with or without the hub.
|
||||||
|
|||||||
@@ -1,178 +1,127 @@
|
|||||||
# Tester - HTTP Contract Test Runner
|
# Tester — HTTP Contract Test Runner
|
||||||
|
|
||||||
Web UI for discovering and running contract tests.
|
Discovers and runs contract tests against any environment, with a web UI for
|
||||||
|
visibility.
|
||||||
|
|
||||||
|
**Test Definitions** → **Tester (Runner + UI)** → **Target API**
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sync tests from production repo (local dev)
|
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
|
||||||
/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh
|
python -m tester discover # list what was found
|
||||||
|
|
||||||
# Run locally
|
|
||||||
cd /home/mariano/wdir/ama/pawprint/ward
|
|
||||||
python -m tools.tester
|
|
||||||
|
|
||||||
# Open in browser
|
# In a built instance, the UI is mounted by the hub:
|
||||||
http://localhost:12003/tester
|
# http://localhost:12000/tools/tester
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Where tests live
|
||||||
|
|
||||||
**Test Definitions** → **Tester (Runner + UI)** → **Target API**
|
**No test bodies are committed to core.** The tool ships the base class, the
|
||||||
|
runner and the UI. Tests belong to a room:
|
||||||
|
|
||||||
```
|
```
|
||||||
amar_django_back_contracts/
|
cfg/<room>/soleprint/station/tools/tester/tests/
|
||||||
└── tests/contracts/ ← Test definitions (source of truth)
|
|
||||||
├── mascotas/
|
|
||||||
├── productos/
|
|
||||||
└── workflows/
|
|
||||||
|
|
||||||
ward/tools/tester/
|
|
||||||
├── tests/ ← Synced from contracts (deployment)
|
|
||||||
│ ├── mascotas/
|
|
||||||
│ ├── productos/
|
|
||||||
│ └── workflows/
|
|
||||||
├── base.py ← HTTP test base class
|
|
||||||
├── core.py ← Test discovery & execution
|
|
||||||
├── api.py ← FastAPI endpoints
|
|
||||||
└── templates/ ← Web UI
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Strategy: Separation of Concerns
|
They are merged into the built instance and discovered from `tests/` there.
|
||||||
|
|
||||||
|
See [`tests/test_template.py`](tests/test_template.py) — an intentionally empty
|
||||||
|
test file whose docstring covers the execution modes, environment targeting, the
|
||||||
|
`ContractTestCase` surface, and a worked example.
|
||||||
|
|
||||||
1. **Tests live in production repo** (`amar_django_back_contracts`)
|
Keeping tests in the room rather than the runner means they version alongside the
|
||||||
- Developers write tests alongside code
|
API they describe, and the runner stays reusable across projects.
|
||||||
- Tests are versioned with the API
|
|
||||||
- PR reviews include test changes
|
|
||||||
|
|
||||||
2. **Tester consumes tests** (`ward/tools/tester`)
|
## Layout
|
||||||
- Provides web UI for visibility
|
|
||||||
- Runs tests against any target (dev, stage, prod)
|
|
||||||
- Shows test coverage to product team
|
|
||||||
|
|
||||||
3. **Deployment syncs tests**
|
```
|
||||||
- `sync-tests.sh` copies tests from contracts to tester
|
tester/
|
||||||
- Deployment script includes test sync
|
├── base.py # ContractTestCase — httpx + stdlib unittest
|
||||||
- Server always has latest tests
|
├── core.py # discovery & execution
|
||||||
|
├── cli.py # python -m tester [discover|run]
|
||||||
|
├── config.py # .env + environment overrides
|
||||||
|
├── api.py # FastAPI routes
|
||||||
|
├── environments.json # named targets
|
||||||
|
├── templates/ # web UI
|
||||||
|
├── gherkin/ # optional feature/scenario metadata mapping
|
||||||
|
├── playwright/ # browser adapter (scaffolded; see the template)
|
||||||
|
└── tests/
|
||||||
|
├── base.py
|
||||||
|
├── test_template.py # start here
|
||||||
|
└── example/ # fallback health check, runs with no room config
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Single Environment (.env)
|
### Single environment (.env)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
CONTRACT_TEST_URL=https://demo.amarmascotas.ar
|
CONTRACT_TEST_URL=https://api.example.com
|
||||||
CONTRACT_TEST_API_KEY=your-api-key-here
|
CONTRACT_TEST_API_KEY=your-api-key-here
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multiple Environments (environments.json)
|
### Multiple environments (environments.json)
|
||||||
|
|
||||||
Configure multiple target environments with individual tokens:
|
Same suite, many targets — this is the point of the tool.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "demo",
|
"id": "local",
|
||||||
"name": "Demo",
|
"name": "Local",
|
||||||
"url": "https://demo.amarmascotas.ar",
|
"url": "http://localhost:8000",
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"description": "Demo environment for testing",
|
"description": "Local development server",
|
||||||
"default": true
|
"default": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dev",
|
"id": "stage",
|
||||||
"name": "Development",
|
"name": "Staging",
|
||||||
"url": "https://dev.amarmascotas.ar",
|
"url": "https://stage.example.com",
|
||||||
"api_key": "dev-token-here",
|
"api_key": "stage-token-here",
|
||||||
"description": "Development environment"
|
"description": "Staging environment"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "prod",
|
|
||||||
"name": "Production",
|
|
||||||
"url": "https://amarmascotas.ar",
|
|
||||||
"api_key": "prod-token-here",
|
|
||||||
"description": "Production (use with caution!)"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Environment Selector**: Available in UI header on both Runner and Filters pages. Selection persists via localStorage.
|
Selection is available in the UI header and persists via localStorage. Tokens are
|
||||||
|
per-environment; keep real ones in a room's gitignored config, never here.
|
||||||
## Web UI Features
|
|
||||||
|
|
||||||
- **Filters**: Advanced filtering by domain, module, status, and search
|
See the template for every `CONTRACT_TEST_*` variable.
|
||||||
- **Runner**: Execute tests with real-time progress tracking
|
|
||||||
- **Multi-Environment**: Switch between dev/stage/prod with per-environment tokens
|
|
||||||
- **URL State**: Filter state persists via URL when running tests
|
|
||||||
- **Real-time Status**: See test results as they run
|
|
||||||
|
|
||||||
## API Endpoints
|
## API
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /tools/tester/ # Runner UI
|
GET /tools/tester/ # Runner UI
|
||||||
GET /tools/tester/filters # Filters UI
|
GET /tools/tester/filters # Filters UI
|
||||||
GET /tools/tester/api/tests # List all tests
|
GET /tools/tester/api/tests # List all tests
|
||||||
|
GET /tools/tester/api/tests/tree # Tests grouped as a tree
|
||||||
GET /tools/tester/api/environments # List environments
|
GET /tools/tester/api/environments # List environments
|
||||||
POST /tools/tester/api/environment/select # Switch environment
|
POST /tools/tester/api/environment/select # Switch environment
|
||||||
POST /tools/tester/api/run # Start test run
|
POST /tools/tester/api/run # Start test run
|
||||||
GET /tools/tester/api/run/{run_id} # Get run status (polling)
|
GET /tools/tester/api/run/{run_id} # Run status (polling)
|
||||||
GET /tools/tester/api/runs # List all runs
|
GET /tools/tester/api/runs # List all runs
|
||||||
|
GET /tools/tester/api/features # Gherkin features
|
||||||
|
POST /tools/tester/api/features/sync # Sync feature files
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage Flow
|
### URL parameters
|
||||||
|
|
||||||
### From Filters to Runner
|
|
||||||
|
|
||||||
1. Go to `/tools/tester/filters`
|
The runner accepts deep links:
|
||||||
2. Filter tests (domain, module, search)
|
|
||||||
3. Select tests to run
|
|
||||||
4. Click "Run Selected"
|
|
||||||
5. → Redirects to Runner with filters applied and auto-starts execution
|
|
||||||
|
|
||||||
### URL Parameters
|
|
||||||
|
|
||||||
Runner accepts URL params for deep linking:
|
|
||||||
|
|
||||||
```
|
|
||||||
/tools/tester/?run=abc123&domains=mascotas&search=owner
|
|
||||||
```
|
```
|
||||||
|
/tools/tester/?run=abc123&modules=customers&search=invoice
|
||||||
- `run` - Auto-load results for this run ID
|
|
||||||
- `domains` - Filter by domains (comma-separated)
|
|
||||||
- `modules` - Filter by modules (comma-separated)
|
|
||||||
- `search` - Search term for test names
|
|
||||||
- `status` - Filter by status (passed,failed,skipped)
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Tests are synced during deployment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Full deployment (includes test sync)
|
|
||||||
cd /home/mariano/wdir/ama/pawprint/deploy
|
|
||||||
./deploy.sh
|
|
||||||
|
|
||||||
# Or sync tests only
|
|
||||||
/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why This Design?
|
- `run` — auto-load results for this run ID
|
||||||
|
- `domains` / `modules` — comma-separated filters
|
||||||
**Problem**: Tests scattered, no visibility, hard to demonstrate value
|
- `search` — search term for test names
|
||||||
|
- `status` — `passed,failed,skipped`
|
||||||
**Solution**:
|
|
||||||
- Tests in production repo (developer workflow)
|
|
||||||
- Tester provides visibility (product team, demos)
|
|
||||||
- Separation allows independent evolution
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Product team sees test coverage
|
|
||||||
- Demos show "quality dashboard"
|
|
||||||
- Tests protect marketplace automation work
|
|
||||||
- Non-devs can run tests via UI
|
|
||||||
|
|
||||||
## Related
|
## Why this design
|
||||||
|
|
||||||
- Production tests: `/home/mariano/wdir/ama/amar_django_back_contracts/tests/contracts/`
|
Tests scattered across repos give no visibility and are hard to demonstrate.
|
||||||
- Sync script: `/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh`
|
Keeping definitions with the API while the runner stays generic means the suite
|
||||||
- Ward system: `/home/mariano/wdir/ama/pawprint/ward/`
|
evolves with the code, non-developers can run it from the UI, and the same tests
|
||||||
|
prove the contract in every environment you can point them at.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
CLI entry point for contracts_http tool.
|
CLI entry point for the tester tool.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m contracts_http discover
|
python -m tester discover
|
||||||
python -m contracts_http run
|
python -m tester run
|
||||||
python -m contracts_http run mascotas
|
python -m tester run customers # only tests matching a pattern
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .cli import main
|
from .cli import main
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def main(args=None):
|
|||||||
|
|
||||||
# run command
|
# run command
|
||||||
run_parser = subparsers.add_parser("run", help="Run tests")
|
run_parser = subparsers.add_parser("run", help="Run tests")
|
||||||
run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g., 'mascotas', 'pet_owners')")
|
run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g. 'customers', 'invoices')")
|
||||||
|
|
||||||
args = parser.parse_args(args)
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,22 @@ Map tests to Gherkin scenarios based on metadata.
|
|||||||
Tests can declare their Gherkin metadata via docstrings:
|
Tests can declare their Gherkin metadata via docstrings:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_coverage_check(self):
|
def test_create_then_read_back(self):
|
||||||
'''
|
'''
|
||||||
Feature: Reservar turno veterinario
|
Feature: Customer records
|
||||||
Scenario: Verificar cobertura en zona disponible
|
Scenario: A created customer can be read back
|
||||||
Tags: @smoke @coverage
|
Tags: @smoke @customers
|
||||||
'''
|
'''
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via class docstrings:
|
Or via class docstrings:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class TestCoverageFlow(ContractHTTPTestCase):
|
class TestCustomers(ContractTestCase):
|
||||||
"""
|
'''
|
||||||
Feature: Reservar turno veterinario
|
Feature: Customer records
|
||||||
Tags: @coverage
|
Tags: @customers
|
||||||
"""
|
'''
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ def sync_features_from_album(
|
|||||||
tester_path: Optional[Path] = None
|
tester_path: Optional[Path] = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Sync .feature files from album/book/gherkin-samples/ to ward/tools/tester/features/.
|
Sync .feature files from an atlas book into the tester's features/ directory.
|
||||||
|
|
||||||
|
Feature files are room-owned and live in a book:
|
||||||
|
cfg/<room>/soleprint/atlas/books/gherkin-samples/
|
||||||
|
which lands at atlas/books/gherkin-samples/ in a built instance. They are
|
||||||
|
synced, not committed here (see features/.gitignore).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
album_path: Path to album/book/gherkin-samples/ (auto-detected if None)
|
album_path: Path to the gherkin-samples book (auto-detected if None)
|
||||||
tester_path: Path to ward/tools/tester/features/ (auto-detected if None)
|
tester_path: Path to tester/features/ (auto-detected if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with sync stats: {synced: int, skipped: int, errors: int}
|
Dict with sync stats: {synced: int, skipped: int, errors: int}
|
||||||
@@ -26,9 +31,9 @@ def sync_features_from_album(
|
|||||||
tester_path = Path(__file__).parent.parent / "features"
|
tester_path = Path(__file__).parent.parent / "features"
|
||||||
|
|
||||||
if album_path is None:
|
if album_path is None:
|
||||||
# Attempt to find album in pawprint
|
# parents[4] is the instance root — station/tools/tester/gherkin/sync.py
|
||||||
pawprint_root = Path(__file__).parent.parent.parent.parent
|
instance_root = Path(__file__).resolve().parents[4]
|
||||||
album_path = pawprint_root / "album" / "book" / "gherkin-samples"
|
album_path = instance_root / "atlas" / "books" / "gherkin-samples"
|
||||||
|
|
||||||
# Ensure paths exist
|
# Ensure paths exist
|
||||||
if not album_path.exists():
|
if not album_path.exists():
|
||||||
|
|||||||
@@ -1,73 +1,42 @@
|
|||||||
# Contract Tests
|
# Contract Tests
|
||||||
|
|
||||||
API contract tests organized by Django app, with optional workflow tests.
|
Black-box HTTP tests that validate an API contract. Framework-agnostic by
|
||||||
|
construction: they talk to a URL, so they run against any implementation and any
|
||||||
|
environment.
|
||||||
|
|
||||||
## Testing Modes
|
**No tests are committed here.** This directory holds the base class and a
|
||||||
|
template; test bodies belong to a room:
|
||||||
|
|
||||||
Two modes via `CONTRACT_TEST_MODE` environment variable:
|
```
|
||||||
|
cfg/<room>/soleprint/station/tools/tester/tests/
|
||||||
| Mode | Command | Description |
|
```
|
||||||
|------|---------|-------------|
|
|
||||||
| **api** (default) | `pytest tests/contracts/` | Fast, Django test client, test DB |
|
|
||||||
| **live** | `CONTRACT_TEST_MODE=live pytest tests/contracts/` | Real HTTP, LiveServerTestCase, test DB |
|
|
||||||
|
|
||||||
### Mode Comparison
|
They are merged into the built instance and discovered from there.
|
||||||
|
|
||||||
| | `api` (default) | `live` |
|
## Start here
|
||||||
|---|---|---|
|
|
||||||
| **Base class** | `APITestCase` | `LiveServerTestCase` |
|
|
||||||
| **HTTP** | In-process (Django test client) | Real HTTP via `requests` |
|
|
||||||
| **Auth** | `force_authenticate()` | JWT tokens via API |
|
|
||||||
| **Database** | Django test DB (isolated) | Django test DB (isolated) |
|
|
||||||
| **Speed** | ~3-5 sec | ~15-30 sec |
|
|
||||||
| **Server** | None (in-process) | Auto-started by Django |
|
|
||||||
|
|
||||||
### Key Point: Both Modes Use Test Database
|
[`test_template.py`](test_template.py) — an intentionally empty test file whose
|
||||||
|
docstring carries the whole story: the execution modes, environment targeting, the
|
||||||
|
`ContractTestCase` surface, and a worked example.
|
||||||
|
|
||||||
Neither mode touches your real database. Django automatically:
|
## The two rules
|
||||||
1. Creates a test database (prefixed with `test_`)
|
|
||||||
2. Runs migrations
|
|
||||||
3. Destroys it after tests complete
|
|
||||||
|
|
||||||
## File Structure
|
1. **One suite, any environment.** A test states what the API promises, never who
|
||||||
|
implements it or where it runs. Point it at a laptop container, a cluster
|
||||||
|
namespace, or a deployed host — the result should only change if the contract
|
||||||
|
broke.
|
||||||
|
|
||||||
```
|
2. **No helper framework tools.** stdlib `unittest` and `httpx`. No pytest
|
||||||
tests/contracts/
|
fixtures or plugins, no framework test client, no factories or DSL, no ORM or
|
||||||
├── base.py # Mode switcher (imports from base_api or base_live)
|
database access. A test that needs framework helpers to express itself has
|
||||||
├── base_api.py # APITestCase implementation
|
stopped testing the contract, and stopped being portable.
|
||||||
├── base_live.py # LiveServerTestCase implementation
|
|
||||||
├── conftest.py # pytest-django configuration
|
|
||||||
├── endpoints.py # API paths (single source of truth)
|
|
||||||
├── helpers.py # Shared test data helpers
|
|
||||||
│
|
|
||||||
├── mascotas/ # Django app: mascotas
|
|
||||||
│ ├── test_pet_owners.py
|
|
||||||
│ ├── test_pets.py
|
|
||||||
│ └── test_coverage.py
|
|
||||||
│
|
|
||||||
├── productos/ # Django app: productos
|
|
||||||
│ ├── test_services.py
|
|
||||||
│ └── test_cart.py
|
|
||||||
│
|
|
||||||
├── solicitudes/ # Django app: solicitudes
|
|
||||||
│ └── test_service_requests.py
|
|
||||||
│
|
|
||||||
└── workflows/ # Multi-step API sequences (e.g., turnero booking flow)
|
|
||||||
└── test_turnero_general.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All contract tests
|
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
|
||||||
pytest tests/contracts/
|
python -m tester discover # list what was found
|
||||||
|
|
||||||
# Single app
|
|
||||||
pytest tests/contracts/mascotas/
|
|
||||||
|
|
||||||
# Single file
|
|
||||||
pytest tests/contracts/mascotas/test_pet_owners.py
|
|
||||||
|
|
||||||
# Live mode (real HTTP)
|
|
||||||
CONTRACT_TEST_MODE=live pytest tests/contracts/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Targets are configured in [`../environments.json`](../environments.json) and
|
||||||
|
selectable from the web UI. See the template for every environment variable.
|
||||||
|
|||||||
143
soleprint/station/tools/tester/tests/test_template.py
Normal file
143
soleprint/station/tools/tester/tests/test_template.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
TEMPLATE — how to write a contract test. Intentionally empty: no test runs from
|
||||||
|
this file, and no tests are committed to the core repo at all.
|
||||||
|
|
||||||
|
Tests belong to a room:
|
||||||
|
|
||||||
|
cfg/<room>/soleprint/station/tools/tester/tests/
|
||||||
|
|
||||||
|
They get merged into the built instance and discovered from there. Core ships the
|
||||||
|
base class, the runner and the UI — never anyone's test bodies.
|
||||||
|
|
||||||
|
|
||||||
|
THE POINT
|
||||||
|
─────────
|
||||||
|
One suite, any environment. A test says what the API promises; it never says who
|
||||||
|
implements it or where it runs. Point the same file at a container on your laptop,
|
||||||
|
a namespace in the cluster, or a deployed host, and the answer should only differ
|
||||||
|
if the contract actually broke.
|
||||||
|
|
||||||
|
That constrains how tests are written, which is the second idea:
|
||||||
|
|
||||||
|
|
||||||
|
NO HELPER FRAMEWORK TOOLS
|
||||||
|
─────────────────────────
|
||||||
|
Deliberately plain. A test makes an HTTP call and asserts on the response.
|
||||||
|
|
||||||
|
- stdlib `unittest`, not pytest fixtures/plugins/parametrize machinery
|
||||||
|
- `httpx` against a URL, not a framework test client
|
||||||
|
- no factories, no DSL, no ORM access, no database setup
|
||||||
|
- no in-process shortcuts — if the test can reach it, so can a client
|
||||||
|
|
||||||
|
The moment a test needs framework helpers to express itself, it has stopped
|
||||||
|
testing the contract and started testing the implementation. It also stops being
|
||||||
|
portable: helper-bound tests only run where that framework runs.
|
||||||
|
|
||||||
|
(Django, where it appears at all, is an optional private DB editor via its admin —
|
||||||
|
never the framework, and never something a test reaches into.)
|
||||||
|
|
||||||
|
|
||||||
|
MODES OF EXECUTION
|
||||||
|
──────────────────
|
||||||
|
A mode is how a test reaches the system under test. Only the first is built.
|
||||||
|
|
||||||
|
http direct BUILT, and the one that matters. `ContractTestCase` below: pure
|
||||||
|
httpx over the wire. Discovered by
|
||||||
|
`unittest.TestLoader().discover(pattern="test_*.py")`, so a test
|
||||||
|
MUST subclass `ContractTestCase` to appear in the runner.
|
||||||
|
|
||||||
|
browser SCAFFOLDED, not wired end to end, and rarely the right tool.
|
||||||
|
`playwright/runner.py` shells out (`npx playwright test
|
||||||
|
--reporter=json`) and parses the report.
|
||||||
|
|
||||||
|
Browser tests earn their cost on complex UIs. Dashboards, monitors
|
||||||
|
and log views are not that — they render what an endpoint already
|
||||||
|
returned, so testing the endpoint tests the dashboard. Reach for
|
||||||
|
this only when behaviour lives in the browser and nowhere else;
|
||||||
|
data visualisation may eventually qualify. Not now.
|
||||||
|
|
||||||
|
any language INTENDED, not built. The browser adapter is the shape it would take:
|
||||||
|
a runner owes only a command to invoke and a machine-readable report
|
||||||
|
to parse. Nothing about that requires the test to be Python — R, Go,
|
||||||
|
k6 or a shell script satisfy the same contract.
|
||||||
|
|
||||||
|
Environment targeting is orthogonal to mode. `environments.json` holds the targets:
|
||||||
|
|
||||||
|
[{"id": "local", "name": "Local", "url": "http://localhost:8000",
|
||||||
|
"api_key": "", "description": "...", "default": true}]
|
||||||
|
|
||||||
|
Select one via `POST /tools/tester/api/environment/select`, or set the env directly:
|
||||||
|
|
||||||
|
CONTRACT_TEST_URL required — base URL of the target
|
||||||
|
CONTRACT_TEST_AUTH_TYPE bearer (default) | api-key | none
|
||||||
|
CONTRACT_TEST_TOKEN bearer token; fetched from the token endpoint if unset
|
||||||
|
CONTRACT_TEST_API_KEY required when auth type is api-key
|
||||||
|
CONTRACT_TEST_TOKEN_ENDPOINT default /api/token/
|
||||||
|
CONTRACT_TEST_USER / CONTRACT_TEST_PASSWORD used to fetch a token
|
||||||
|
|
||||||
|
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
|
||||||
|
|
||||||
|
|
||||||
|
WRITING ONE
|
||||||
|
───────────
|
||||||
|
The domain below is the in-repo invoicing fixture (cfg/sample) — deliberately
|
||||||
|
generic. Substitute your own; the shape is the part that transfers.
|
||||||
|
|
||||||
|
from ..base import ContractTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomers(ContractTestCase):
|
||||||
|
'''Customer endpoints.'''
|
||||||
|
|
||||||
|
def test_list_returns_customers(self):
|
||||||
|
'''A list endpoint returns a list.'''
|
||||||
|
response = self.get("/api/customers/")
|
||||||
|
self.assert_status(response, 200)
|
||||||
|
self.assert_is_list(response.data)
|
||||||
|
|
||||||
|
def test_create_then_read_back(self):
|
||||||
|
'''What was written is what comes back.'''
|
||||||
|
created = self.post("/api/customers/", {"name": "Acme Ltd"})
|
||||||
|
self.assert_status(created, 201)
|
||||||
|
self.assert_has_fields(created.data, "id", "name")
|
||||||
|
|
||||||
|
fetched = self.get(f"/api/customers/{created.data['id']}/")
|
||||||
|
self.assert_status(fetched, 200)
|
||||||
|
self.assertEqual(fetched.data["name"], "Acme Ltd")
|
||||||
|
|
||||||
|
def test_unknown_customer_is_404(self):
|
||||||
|
'''Absence is reported, not guessed at.'''
|
||||||
|
self.assert_status(self.get("/api/customers/00000000/"), 404)
|
||||||
|
|
||||||
|
Inherited from `ContractTestCase` (see ../base.py) — this is the whole surface:
|
||||||
|
|
||||||
|
self.get / post / put / patch / delete auth headers applied, .data parsed
|
||||||
|
self.assert_status(response, code)
|
||||||
|
self.assert_has_fields(data, *names)
|
||||||
|
self.assert_is_list(data, min_length=0)
|
||||||
|
self.base_url / self.token / self.api_key
|
||||||
|
|
||||||
|
Conventions that keep a suite portable:
|
||||||
|
|
||||||
|
- Group by area in subfolders; put multi-step sequences under `workflows/`.
|
||||||
|
- Keep paths in one `endpoints.py` per room so a URL change is a one-line diff.
|
||||||
|
- Create what you need through the API and assert on what comes back. A test that
|
||||||
|
depends on data already being there only passes in the environment it was
|
||||||
|
written against, which defeats the point.
|
||||||
|
- Skip, don't fail, when the target is simply unreachable — an unreachable
|
||||||
|
environment is not a broken contract.
|
||||||
|
|
||||||
|
|
||||||
|
GHERKIN (OPTIONAL)
|
||||||
|
──────────────────
|
||||||
|
A test can declare feature/scenario metadata in its docstring; the mapper reads it
|
||||||
|
to tie runs back to `.feature` files. Feature files are synced, not committed
|
||||||
|
(see features/.gitignore).
|
||||||
|
|
||||||
|
def test_create_then_read_back(self):
|
||||||
|
'''
|
||||||
|
Feature: Customer records
|
||||||
|
Scenario: A created customer can be read back
|
||||||
|
Tags: @smoke @customers
|
||||||
|
'''
|
||||||
|
"""
|
||||||
Reference in New Issue
Block a user