update 33.1 50
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -9,6 +9,13 @@ __pycache__/
|
||||
.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)
|
||||
gen/
|
||||
|
||||
|
||||
73
CLAUDE.md
73
CLAUDE.md
@@ -51,20 +51,19 @@ spr/
|
||||
│ └── amar/ # Amar room config
|
||||
│ ├── config.json
|
||||
│ ├── data/
|
||||
│ ├── artery/ # Amar-specific (merged into output)
|
||||
│ │ └── shunts/amar/
|
||||
│ ├── atlas/ # Amar-specific books
|
||||
│ │ └── books/
|
||||
│ ├── station/ # Amar-specific tools config
|
||||
│ │ └── tools/datagen/
|
||||
│ ├── soleprint/ # Room overlay — merged over soleprint/ on build
|
||||
│ │ ├── artery/ # room shunts, pulses
|
||||
│ │ │ └── shunts/amar/
|
||||
│ │ ├── atlas/ # room books
|
||||
│ │ │ └── books/
|
||||
│ │ ├── station/ # room tool configs
|
||||
│ │ │ └── tools/datagen/
|
||||
│ │ └── nginx/
|
||||
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
|
||||
│ ├── link/ # Bridge to managed app
|
||||
│ ├── soleprint/ # Soleprint docker config
|
||||
│ ├── databrowse/
|
||||
│ ├── tester/
|
||||
│ ├── monitors/
|
||||
│ └── models/
|
||||
│ └── amar/ # The managed app itself
|
||||
│
|
||||
├── ctrl/ # Build/run scripts
|
||||
├── ctrl/ # Build/run scripts (see Build & Run)
|
||||
│
|
||||
└── gen/ # Built instances (gitignored)
|
||||
├── standalone/
|
||||
@@ -100,26 +99,38 @@ Each room in `cfg/` has:
|
||||
- `config.json` - Framework branding/terminology
|
||||
- `data/` - Data files (veins.json, shunts.json, etc.)
|
||||
|
||||
Room-specific system configs (merged into output):
|
||||
- `artery/` - Room-specific shunts, pulses
|
||||
- `atlas/` - Room-specific books
|
||||
- `station/` - Room-specific tool configs (datagen, tester tests, etc.)
|
||||
Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
|
||||
the core `soleprint/` tree at build time:
|
||||
- `soleprint/artery/` - Room-specific shunts, pulses
|
||||
- `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
|
||||
|
||||
`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
|
||||
# Build
|
||||
python build.py # -> gen/standalone/
|
||||
python build.py --cfg amar # -> gen/amar/
|
||||
python build.py --all # -> all rooms
|
||||
make # = make help
|
||||
make build [room|all|models] # -> gen/<room>/ (default: standalone)
|
||||
make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
|
||||
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
|
||||
cd gen/standalone && python run.py
|
||||
Every script stays runnable on its own — the standalone rule holds:
|
||||
|
||||
# Using ctrl scripts
|
||||
./ctrl/build.sh [room]
|
||||
./ctrl/start.sh [room] [-d]
|
||||
./ctrl/stop.sh [room]
|
||||
```bash
|
||||
python build.py --cfg amar # -> gen/amar/
|
||||
cd gen/standalone && python run.py # bare-metal
|
||||
./ctrl/kind-up.sh # still works directly
|
||||
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
|
||||
```
|
||||
|
||||
## Adding a New Room
|
||||
@@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data
|
||||
cp cfg/standalone/config.json cfg/newroom/
|
||||
cp -r cfg/standalone/data/* cfg/newroom/data/
|
||||
|
||||
# Add room-specific configs as needed:
|
||||
# cfg/newroom/artery/shunts/...
|
||||
# cfg/newroom/atlas/books/...
|
||||
# cfg/newroom/station/tools/...
|
||||
# Add room-specific configs as needed (note the soleprint/ overlay level):
|
||||
# cfg/newroom/soleprint/artery/shunts/...
|
||||
# cfg/newroom/soleprint/atlas/books/...
|
||||
# cfg/newroom/soleprint/station/tools/datagen/<name>.py
|
||||
|
||||
python build.py --cfg newroom
|
||||
make build newroom
|
||||
```
|
||||
|
||||
## 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:
|
||||
build(SPR_ROOT / "gen" / "standalone", None)
|
||||
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)
|
||||
else:
|
||||
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
|
||||
49
ctrl/spr.py
49
ctrl/spr.py
@@ -11,6 +11,7 @@ Usage:
|
||||
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 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
|
||||
"""
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
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):
|
||||
registry = load_registry()
|
||||
comp_type, source = resolve_component(registry, args.component)
|
||||
@@ -202,12 +234,17 @@ def cmd_publish(args):
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
|
||||
count = copy_tree(source, dest)
|
||||
write_stamp(dest, args.component, comp_type, source, "published")
|
||||
if args.dist:
|
||||
count = publish_dist(source, dest)
|
||||
mode = "published-dist"
|
||||
else:
|
||||
count = copy_tree(source, dest)
|
||||
mode = "published"
|
||||
write_stamp(dest, args.component, comp_type, source, mode)
|
||||
|
||||
version = get_version(comp_type, source)
|
||||
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):
|
||||
@@ -306,6 +343,12 @@ def main():
|
||||
p = sub.add_parser(cmd)
|
||||
p.add_argument("component", help="component name")
|
||||
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.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,
|
||||
"type": "module",
|
||||
"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": {
|
||||
"build": "vite build",
|
||||
"build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"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
|
||||
|
||||
// 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 { SSEDataSource } from './datasources/SSEDataSource'
|
||||
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
|
||||
|
||||
- Generate realistic test data for Amar domain models
|
||||
- Generate mock API responses for external services (MercadoPago, etc.)
|
||||
- Can be plugged into any nest (test suites, mock veins, seeders)
|
||||
- Domain-agnostic and reusable
|
||||
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
|
||||
instance — so no client's domain vocabulary lives here.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
datagen/
|
||||
├── __init__.py
|
||||
├── amar.py # Amar domain models (petowner, pet, cart, etc.)
|
||||
├── mercadopago.py # MercadoPago API responses
|
||||
└── README.md # This file
|
||||
├── base.py # BaseDataGenerator — the contract
|
||||
├── api.py # FastAPI router, mounted at /tools/datagen
|
||||
├── 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
|
||||
from ward.tools.datagen.amar import AmarDataGenerator
|
||||
from faker import Faker
|
||||
|
||||
def test_petowner_creation():
|
||||
owner_data = AmarDataGenerator.petowner(address="Av. Corrientes 1234")
|
||||
assert owner_data["address"] == "Av. Corrientes 1234"
|
||||
```
|
||||
# Guarded so the file also runs on its own, outside a built instance —
|
||||
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
|
||||
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
|
||||
from ward.tools.datagen.amar import AmarDataGenerator
|
||||
|
||||
# Create 10 test pet owners
|
||||
for i in range(10):
|
||||
owner = AmarDataGenerator.petowner(is_guest=False)
|
||||
# Save to database...
|
||||
def product(self, category=None, **kwargs):
|
||||
return {"id": fake.uuid4(), "name": fake.word(), "category": category, **kwargs}
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
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
|
||||
Drop it in `cfg/<room>/soleprint/station/tools/datagen/<name>.py` and rebuild the room.
|
||||
|
||||
## 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)
|
||||
- `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
|
||||
## HTTP API
|
||||
|
||||
### 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
|
||||
- `payment()` - Payment (Checkout API/Bricks)
|
||||
- `merchant_order()` - Merchant order
|
||||
- `oauth_token()` - OAuth token exchange
|
||||
- `webhook_notification()` - Webhook payloads
|
||||
With one generator loaded, `generator` can be omitted everywhere — the only one is used.
|
||||
|
||||
## Examples
|
||||
|
||||
### Generate a complete turnero flow
|
||||
|
||||
```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"])
|
||||
)
|
||||
```bash
|
||||
curl -X POST localhost:12000/tools/datagen/api/generate \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model": "user", "count": 3}'
|
||||
```
|
||||
|
||||
## Future Generators
|
||||
## Design principles
|
||||
|
||||
- `google.py` - Google API responses (Calendar, Sheets)
|
||||
- `whatsapp.py` - WhatsApp API responses
|
||||
- `slack.py` - Slack API responses
|
||||
1. **Room-owned** — domain vocabulary lives in `cfg/<room>/`, never in core.
|
||||
2. **Convention over registration** — a method name is a model name.
|
||||
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
|
||||
|
||||
```bash
|
||||
# Sync tests from production repo (local dev)
|
||||
/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh
|
||||
|
||||
# Run locally
|
||||
cd /home/mariano/wdir/ama/pawprint/ward
|
||||
python -m tools.tester
|
||||
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
|
||||
python -m tester discover # list what was found
|
||||
|
||||
# Open in browser
|
||||
http://localhost:12003/tester
|
||||
# In a built instance, the UI is mounted by the hub:
|
||||
# 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/
|
||||
└── 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
|
||||
|
||||
cfg/<room>/soleprint/station/tools/tester/tests/
|
||||
```
|
||||
|
||||
## 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`)
|
||||
- Developers write tests alongside code
|
||||
- Tests are versioned with the API
|
||||
- PR reviews include test changes
|
||||
Keeping tests in the room rather than the runner means they version alongside the
|
||||
API they describe, and the runner stays reusable across projects.
|
||||
|
||||
2. **Tester consumes tests** (`ward/tools/tester`)
|
||||
- Provides web UI for visibility
|
||||
- Runs tests against any target (dev, stage, prod)
|
||||
- Shows test coverage to product team
|
||||
## Layout
|
||||
|
||||
3. **Deployment syncs tests**
|
||||
- `sync-tests.sh` copies tests from contracts to tester
|
||||
- Deployment script includes test sync
|
||||
- Server always has latest tests
|
||||
```
|
||||
tester/
|
||||
├── base.py # ContractTestCase — httpx + stdlib unittest
|
||||
├── 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
|
||||
|
||||
### Single Environment (.env)
|
||||
### Single environment (.env)
|
||||
|
||||
```env
|
||||
CONTRACT_TEST_URL=https://demo.amarmascotas.ar
|
||||
CONTRACT_TEST_URL=https://api.example.com
|
||||
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
|
||||
[
|
||||
{
|
||||
"id": "demo",
|
||||
"name": "Demo",
|
||||
"url": "https://demo.amarmascotas.ar",
|
||||
"id": "local",
|
||||
"name": "Local",
|
||||
"url": "http://localhost:8000",
|
||||
"api_key": "",
|
||||
"description": "Demo environment for testing",
|
||||
"description": "Local development server",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Development",
|
||||
"url": "https://dev.amarmascotas.ar",
|
||||
"api_key": "dev-token-here",
|
||||
"description": "Development environment"
|
||||
},
|
||||
{
|
||||
"id": "prod",
|
||||
"name": "Production",
|
||||
"url": "https://amarmascotas.ar",
|
||||
"api_key": "prod-token-here",
|
||||
"description": "Production (use with caution!)"
|
||||
"id": "stage",
|
||||
"name": "Staging",
|
||||
"url": "https://stage.example.com",
|
||||
"api_key": "stage-token-here",
|
||||
"description": "Staging environment"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Environment Selector**: Available in UI header on both Runner and Filters pages. Selection persists via localStorage.
|
||||
|
||||
## Web UI Features
|
||||
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.
|
||||
|
||||
- **Filters**: Advanced filtering by domain, module, status, and search
|
||||
- **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
|
||||
See the template for every `CONTRACT_TEST_*` variable.
|
||||
|
||||
## API Endpoints
|
||||
## API
|
||||
|
||||
```
|
||||
GET /tools/tester/ # Runner UI
|
||||
GET /tools/tester/filters # Filters UI
|
||||
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
|
||||
POST /tools/tester/api/environment/select # Switch environment
|
||||
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/features # Gherkin features
|
||||
POST /tools/tester/api/features/sync # Sync feature files
|
||||
```
|
||||
|
||||
## Usage Flow
|
||||
|
||||
### From Filters to Runner
|
||||
### URL parameters
|
||||
|
||||
1. Go to `/tools/tester/filters`
|
||||
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
|
||||
The runner accepts deep links:
|
||||
|
||||
### URL Parameters
|
||||
|
||||
Runner accepts URL params for deep linking:
|
||||
|
||||
```
|
||||
/tools/tester/?run=abc123&domains=mascotas&search=owner
|
||||
```
|
||||
|
||||
- `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
|
||||
/tools/tester/?run=abc123&modules=customers&search=invoice
|
||||
```
|
||||
|
||||
## Why This Design?
|
||||
|
||||
**Problem**: Tests scattered, no visibility, hard to demonstrate value
|
||||
|
||||
**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
|
||||
- `run` — auto-load results for this run ID
|
||||
- `domains` / `modules` — comma-separated filters
|
||||
- `search` — search term for test names
|
||||
- `status` — `passed,failed,skipped`
|
||||
|
||||
## Related
|
||||
## Why this design
|
||||
|
||||
- Production tests: `/home/mariano/wdir/ama/amar_django_back_contracts/tests/contracts/`
|
||||
- Sync script: `/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh`
|
||||
- Ward system: `/home/mariano/wdir/ama/pawprint/ward/`
|
||||
Tests scattered across repos give no visibility and are hard to demonstrate.
|
||||
Keeping definitions with the API while the runner stays generic means the suite
|
||||
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:
|
||||
python -m contracts_http discover
|
||||
python -m contracts_http run
|
||||
python -m contracts_http run mascotas
|
||||
python -m tester discover
|
||||
python -m tester run
|
||||
python -m tester run customers # only tests matching a pattern
|
||||
"""
|
||||
|
||||
from .cli import main
|
||||
|
||||
@@ -113,7 +113,7 @@ def main(args=None):
|
||||
|
||||
# run command
|
||||
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)
|
||||
|
||||
|
||||
@@ -4,22 +4,22 @@ Map tests to Gherkin scenarios based on metadata.
|
||||
Tests can declare their Gherkin metadata via docstrings:
|
||||
|
||||
```python
|
||||
def test_coverage_check(self):
|
||||
def test_create_then_read_back(self):
|
||||
'''
|
||||
Feature: Reservar turno veterinario
|
||||
Scenario: Verificar cobertura en zona disponible
|
||||
Tags: @smoke @coverage
|
||||
Feature: Customer records
|
||||
Scenario: A created customer can be read back
|
||||
Tags: @smoke @customers
|
||||
'''
|
||||
```
|
||||
|
||||
Or via class docstrings:
|
||||
|
||||
```python
|
||||
class TestCoverageFlow(ContractHTTPTestCase):
|
||||
"""
|
||||
Feature: Reservar turno veterinario
|
||||
Tags: @coverage
|
||||
"""
|
||||
class TestCustomers(ContractTestCase):
|
||||
'''
|
||||
Feature: Customer records
|
||||
Tags: @customers
|
||||
'''
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
@@ -12,11 +12,16 @@ def sync_features_from_album(
|
||||
tester_path: Optional[Path] = None
|
||||
) -> 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:
|
||||
album_path: Path to album/book/gherkin-samples/ (auto-detected if None)
|
||||
tester_path: Path to ward/tools/tester/features/ (auto-detected if None)
|
||||
album_path: Path to the gherkin-samples book (auto-detected if None)
|
||||
tester_path: Path to tester/features/ (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
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"
|
||||
|
||||
if album_path is None:
|
||||
# Attempt to find album in pawprint
|
||||
pawprint_root = Path(__file__).parent.parent.parent.parent
|
||||
album_path = pawprint_root / "album" / "book" / "gherkin-samples"
|
||||
# parents[4] is the instance root — station/tools/tester/gherkin/sync.py
|
||||
instance_root = Path(__file__).resolve().parents[4]
|
||||
album_path = instance_root / "atlas" / "books" / "gherkin-samples"
|
||||
|
||||
# Ensure paths exist
|
||||
if not album_path.exists():
|
||||
|
||||
@@ -1,73 +1,42 @@
|
||||
# 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:
|
||||
|
||||
| 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 |
|
||||
```
|
||||
cfg/<room>/soleprint/station/tools/tester/tests/
|
||||
```
|
||||
|
||||
### Mode Comparison
|
||||
They are merged into the built instance and discovered from there.
|
||||
|
||||
| | `api` (default) | `live` |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
## Start here
|
||||
|
||||
### 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:
|
||||
1. Creates a test database (prefixed with `test_`)
|
||||
2. Runs migrations
|
||||
3. Destroys it after tests complete
|
||||
## The two rules
|
||||
|
||||
## 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.
|
||||
|
||||
```
|
||||
tests/contracts/
|
||||
├── base.py # Mode switcher (imports from base_api or base_live)
|
||||
├── base_api.py # APITestCase implementation
|
||||
├── 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
|
||||
```
|
||||
2. **No helper framework tools.** stdlib `unittest` and `httpx`. No pytest
|
||||
fixtures or plugins, no framework test client, no factories or DSL, no ORM or
|
||||
database access. A test that needs framework helpers to express itself has
|
||||
stopped testing the contract, and stopped being portable.
|
||||
|
||||
## Running Tests
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# All contract tests
|
||||
pytest tests/contracts/
|
||||
|
||||
# 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/
|
||||
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
|
||||
python -m tester discover # list what was found
|
||||
```
|
||||
|
||||
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